diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs index 0e4dc0dfc..7d94328f8 100644 --- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs @@ -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; @@ -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(() => executor.ExecuteAsync(toolCall, context, TestContext.Current.CancellationToken)); Assert.Equal("shell_disabled", ex.DenyReason); } @@ -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(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(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(); + 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() { @@ -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(() => 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); } @@ -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); } @@ -1142,6 +1324,103 @@ await approvalService.RecordApprovalAsync( } } + private sealed class UnexpectedApprovalService : IToolApprovalService + { + public Task CheckApprovalAsync( + ToolApprovalSessionId? sessionId, + TrustAudience audience, + ToolName toolName, + IReadOnlyList candidates, + string? cwd, + CancellationToken ct = default) + => throw new InvalidOperationException("The approval-exempt path must not query stored approvals."); + + public Task> GetUnapprovedPatternsAsync( + ToolApprovalSessionId? sessionId, + TrustAudience audience, + ToolName toolName, + IReadOnlyList 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 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 CheckApprovalAsync( + ToolApprovalSessionId? sessionId, + TrustAudience audience, + ToolName toolName, + IReadOnlyList candidates, + string? cwd, + CancellationToken ct = default) + => Task.FromResult(result); + + public Task> GetUnapprovedPatternsAsync( + ToolApprovalSessionId? sessionId, + TrustAudience audience, + ToolName toolName, + IReadOnlyList 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 patterns, + bool persistent, + string? cwd, + CancellationToken ct = default) + => throw new InvalidOperationException("The authorization evaluator must not record an approval."); + } + + private sealed class RecordingLogger : ILogger + { + public List> Entries { get; } = []; + + public IDisposable BeginScope(TState state) where TState : notnull + => EmptyScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (state is not IEnumerable> 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 { private readonly IActorRef _actor; diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs index e49302f4d..83d4201cc 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs @@ -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}"); + Directory.CreateDirectory(projectDirectory); + try + { + var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; + config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig + { + ToolOverrides = new Dictionary(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] diff --git a/src/Netclaw.Actors.Tests/Tools/ToolExecutionValueObjectTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolExecutionValueObjectTests.cs index 8b28b962a..d5a7d9ae6 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolExecutionValueObjectTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolExecutionValueObjectTests.cs @@ -3,6 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Netclaw.Actors.Tools; using Netclaw.Configuration; using Netclaw.Media; @@ -10,6 +11,17 @@ namespace Netclaw.Actors.Tests.Tools; public sealed class ToolExecutionValueObjectTests { + [Fact] + public void Every_allow_reason_has_a_distinct_human_explanation() + { + var descriptions = Enum.GetValues() + .Select(reason => reason.GetDescription()) + .ToList(); + + Assert.All(descriptions, description => Assert.False(string.IsNullOrWhiteSpace(description))); + Assert.Equal(descriptions.Count, descriptions.Distinct(StringComparer.Ordinal).Count()); + } + [Theory] [InlineData(0)] [InlineData(-1)] diff --git a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs index 8a540a666..17af0f5ea 100644 --- a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs +++ b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs @@ -168,7 +168,7 @@ public async Task ExecuteAsync(FunctionCallContent toolCall, ToolExecuti return rejection.Message; } - var tool = await AuthorizeCoreAsync(toolCall, context, ct); + var tool = await GetAuthorizedToolAsync(toolCall, context, ct); var sw = Stopwatch.StartNew(); try @@ -210,7 +210,7 @@ public async Task ExecuteAsync(FunctionCallContent toolCall, ToolExecuti public async Task AuthorizeAsync(FunctionCallContent toolCall, ToolExecutionContext context, CancellationToken ct = default) { - _ = await AuthorizeCoreAsync(toolCall, context, ct); + _ = await GetAuthorizedToolAsync(toolCall, context, ct); } // The tool's own override (verbose tools like shell opt down) wins; otherwise @@ -247,7 +247,7 @@ public async IAsyncEnumerable ExecuteStreamAsync( // Authorization throws (ToolApprovalRequiredException / ToolAccessDeniedException) // before the first item is produced; the tool-execution pipeline handles // those exactly as it does for the non-streaming path. - var tool = await AuthorizeCoreAsync(toolCall, context, ct); + var tool = await GetAuthorizedToolAsync(toolCall, context, ct); var sw = Stopwatch.StartNew(); await foreach (var update in tool.ExecuteStreamAsync(toolCall.Arguments, context.Invocation, ct)) { @@ -274,18 +274,30 @@ public async IAsyncEnumerable ExecuteStreamAsync( } } - private async Task AuthorizeCoreAsync(FunctionCallContent toolCall, ToolExecutionContext context, CancellationToken ct) + /// + /// Evaluates the complete authorization gate before a tool runs or a user receives a prompt. + /// + /// + /// This method returns expected authorization outcomes instead of exceptions. + /// Execution adapters translate the result into the existing pipeline exceptions. + /// + internal async Task EvaluateAuthorizationAsync( + FunctionCallContent toolCall, + ToolExecutionContext context, + CancellationToken ct) { context.Approval.ClearAppliedDecision(); var tool = _registry.GetByName(toolCall.Name); if (tool is null) { - _logger.LogWarning("Unknown tool requested: {ToolName}", toolCall.Name); - throw new ToolAccessDeniedException("tool_not_found"); + var missingToolDecision = ToolAuthorizationDecision.Deny("tool_not_found"); + LogAuthorizationDecision(toolCall.Name, missingToolDecision); + return missingToolDecision; } var accessDecision = _policy.AuthorizeInvocation(tool, context, toolCall.Arguments); + IReadOnlyList approvalMatches = []; if (accessDecision.NeedsApproval && _approvalService is not null) { @@ -328,7 +340,7 @@ private async Task AuthorizeCoreAsync(FunctionCallContent toolCall if (candidatesForCheck.Count == 0) { // Every candidate is side-effect-only — auto-allow. - accessDecision = ToolAccessDecision.Allow(); + accessDecision = ToolAccessDecision.Allow(ToolAllowReason.ApprovalExemptShellCandidates); } else { @@ -348,14 +360,17 @@ private async Task AuthorizeCoreAsync(FunctionCallContent toolCall candidatesForCheck, context.Approval.Cwd, ct); + approvalMatches = approvalCheck.ApprovedMatches; if (approvalCheck.UnapprovedPatterns.Count == 0) + { context.Approval.ApplyDecision( "PreviouslyApproved", FormatApprovalMatches(approvalCheck.ApprovedMatches)); + } accessDecision = approvalCheck.UnapprovedPatterns.Count == 0 - ? ToolAccessDecision.Allow() + ? ToolAccessDecision.Allow(ToolAllowReason.StoredApproval) : ToolAccessDecision.RequiresApproval(approvalContext); } } @@ -364,26 +379,95 @@ private async Task AuthorizeCoreAsync(FunctionCallContent toolCall if (accessDecision.NeedsApproval && IsOneTimeApprovalSatisfied(context, toolCall, accessDecision.ApprovalContext)) { - _logger.LogInformation( - "Applying one-time approval bypass for tool {ToolName} in session {SessionId}", - toolCall.Name, - context.SessionId ?? "unknown"); - accessDecision = ToolAccessDecision.Allow(); + accessDecision = ToolAccessDecision.Allow(ToolAllowReason.OneTimeApproval); + } + + var authorizationDecision = CompleteAuthorizationDecision(accessDecision, approvalMatches); + LogAuthorizationDecision(toolCall.Name, authorizationDecision); + return authorizationDecision; + } + + private async Task GetAuthorizedToolAsync( + FunctionCallContent toolCall, + ToolExecutionContext context, + CancellationToken ct) + { + var decision = await EvaluateAuthorizationAsync(toolCall, context, ct); + + if (decision.Outcome is ToolAuthorizationOutcome.RequiresApproval) + { + throw new ToolApprovalRequiredException( + decision.ApprovalContext + ?? throw new InvalidOperationException("Approval decision missing approval context.")); } + if (decision.Outcome is ToolAuthorizationOutcome.Denied) + { + throw new ToolAccessDeniedException( + decision.DenyReason + ?? throw new InvalidOperationException("Denied decision missing a deny reason.")); + } + + return _registry.GetByName(toolCall.Name) + ?? throw new InvalidOperationException("Allowed decision missing its registered tool."); + } + + private static ToolAuthorizationDecision CompleteAuthorizationDecision( + ToolAccessDecision accessDecision, + IReadOnlyList approvalMatches) + { if (accessDecision.NeedsApproval) { - _logger.LogInformation("Tool requires approval: {ToolName}", toolCall.Name); - throw new ToolApprovalRequiredException(accessDecision.ApprovalContext!); + return ToolAuthorizationDecision.RequiresApproval( + accessDecision.ApprovalContext + ?? throw new InvalidOperationException("Approval decision missing approval context."), + approvalMatches); } if (!accessDecision.Allowed) { - _logger.LogWarning("Tool denied by policy: {ToolName} reason={Reason}", toolCall.Name, accessDecision.DenyReason); - throw new ToolAccessDeniedException(accessDecision.DenyReason ?? "tool_denied"); + return ToolAuthorizationDecision.Deny( + accessDecision.DenyReason + ?? throw new InvalidOperationException("Denied decision missing a deny reason.")); } - return tool; + return ToolAuthorizationDecision.Allow( + accessDecision.AllowReason + ?? throw new InvalidOperationException("Allowed decision missing an allow reason."), + approvalMatches); + } + + private void LogAuthorizationDecision(string toolName, ToolAuthorizationDecision decision) + { + switch (decision.Outcome) + { + case ToolAuthorizationOutcome.Allowed: + var allowReason = decision.AllowReason + ?? throw new InvalidOperationException("Allowed decision missing an allow reason."); + _logger.LogDebug( + "Tool authorization evaluated: {ToolName} outcome={AuthorizationOutcome} " + + "reason={AuthorizationReason} explanation={AuthorizationExplanation}", + toolName, + decision.Outcome.ToString(), + allowReason.ToString(), + allowReason.GetDescription()); + break; + case ToolAuthorizationOutcome.RequiresApproval: + _logger.LogInformation( + "Tool authorization evaluated: {ToolName} outcome={AuthorizationOutcome}", + toolName, + decision.Outcome.ToString()); + break; + case ToolAuthorizationOutcome.Denied: + _logger.LogWarning( + "Tool authorization evaluated: {ToolName} outcome={AuthorizationOutcome} reason={AuthorizationReason}", + toolName, + decision.Outcome.ToString(), + decision.DenyReason); + break; + default: + throw new ArgumentOutOfRangeException(nameof(decision), decision.Outcome, "Unknown authorization outcome."); + } } private static string FormatApprovalMatches(IReadOnlyList matches) diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index 28952072f..4d3aa1a5f 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -289,7 +289,7 @@ private ToolAccessDecision CheckApprovalGate( return ToolAccessDecision.Deny("tool_denied_by_approval_policy"); if (mode == ToolApprovalMode.Auto) - return ToolAccessDecision.Allow(); + return ToolAccessDecision.Allow(ToolAllowReason.PolicyAuto); // The approval policy is authoritative for every channel — there is no // safe-list auto-grant for non-interactive callers. A non-interactive @@ -340,7 +340,7 @@ private ToolAccessDecision CheckApprovalGate( && candidateVerbs.Count > 0 && _safeVerbPolicy.AllShortCircuit(candidateVerbs, context.Approval.Cwd, context.Invocation)) { - return ToolAccessDecision.Allow(); + return ToolAccessDecision.Allow(ToolAllowReason.SafeVerbInTrustedScope); } var options = BuildApprovalOptions( @@ -605,11 +605,18 @@ public sealed record FeatureGates( public sealed record ToolAccessDecision(bool Allowed, string? DenyReason = null, ToolApprovalContext? ApprovalContext = null) { + /// + /// Gets the reason for an allowed access decision. + /// + internal ToolAllowReason? AllowReason { get; private init; } + /// True when the decision is . public bool NeedsApproval => ApprovalContext is not null && Allowed; public static ToolAccessDecision Allow() => new(true); + internal static ToolAccessDecision Allow(ToolAllowReason reason) => new(true) { AllowReason = reason }; + public static ToolAccessDecision Deny(string reason) => new(false, reason); public static ToolAccessDecision RequiresApproval(ToolApprovalContext context) => new(true, null, context); diff --git a/src/Netclaw.Actors/Tools/ToolAuthorizationDecision.cs b/src/Netclaw.Actors/Tools/ToolAuthorizationDecision.cs new file mode 100644 index 000000000..2e27da01d --- /dev/null +++ b/src/Netclaw.Actors/Tools/ToolAuthorizationDecision.cs @@ -0,0 +1,250 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Security; + +namespace Netclaw.Actors.Tools; + +/// +/// Specifies the authorization outcome for one tool invocation attempt. +/// +internal enum ToolAuthorizationOutcome +{ + /// + /// The current attempt can execute without a user prompt. + /// + /// + /// The decision contains an value. + /// A later tool failure does not change this authorization outcome. + /// + Allowed, + + /// + /// The current attempt cannot execute until the user grants approval. + /// + /// + /// The decision contains a value. + /// This outcome describes the authorization gate before a user response. + /// A caller without an approval channel must fail closed. + /// + RequiresApproval, + + /// + /// The current attempt cannot execute and must not prompt the user. + /// + /// + /// The decision contains a stable deny reason. + /// A new user approval cannot override this outcome. + /// + Denied +} + +/// +/// Specifies the rule that allowed one tool invocation attempt. +/// +internal enum ToolAllowReason +{ + /// + /// The resolved approval policy sets the tool call to Auto. + /// + /// + /// This value covers explicit overrides and effective profile defaults. + /// It does not cover safe verbs or prior approval grants. + /// + PolicyAuto, + + /// + /// The shell safe-verb policy allows every command candidate. + /// + /// + /// The parser must produce a clean candidate set. + /// Each verb must occur in the safe-verb list. + /// Each effective directory must occur inside an applicable safe area. + /// + SafeVerbInTrustedScope, + + /// + /// Every parsed shell candidate belongs to the fixed approval-exempt set. + /// + /// + /// The current set contains echo, printf, :, + /// true, and false. + /// A path or redirect disqualifies a candidate. + /// Other candidates in the same command require separate authorization. + /// This reason does not claim that the complete shell expression has no effects. + /// + ApprovalExemptShellCandidates, + + /// + /// Existing approval grants match every candidate that requires a grant. + /// + /// + /// The decision contains the matched grants as structured evidence. + /// One compound call can use session and persistent approval sources. + /// + StoredApproval, + + /// + /// A one-time grant from an earlier user response allows this retry. + /// + /// + /// The tool name and all extracted patterns must match the retry state. + /// This value does not represent a session or persistent approval. + /// The pipeline clears the retry state after the attempt. + /// + OneTimeApproval +} + +/// +/// Provides operator-facing explanations for tool allow reasons. +/// +internal static class ToolAllowReasonExtensions +{ + /// + /// Gets a human-readable explanation for an allow reason. + /// + /// The allow reason. + /// A short explanation for logs and diagnostics. + /// + /// The reason is not a defined value. + /// + public static string GetDescription(this ToolAllowReason reason) + => reason switch + { + ToolAllowReason.PolicyAuto => + "The resolved approval policy allowed the tool automatically.", + ToolAllowReason.SafeVerbInTrustedScope => + "The shell safe-verb policy allowed every candidate inside a trusted scope.", + ToolAllowReason.ApprovalExemptShellCandidates => + "Every parsed shell candidate was exempt from stored approval checks.", + ToolAllowReason.StoredApproval => + "Existing approval grants matched every candidate that required a grant.", + ToolAllowReason.OneTimeApproval => + "A one-time approval matched this invocation retry.", + _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, "Unknown tool allow reason.") + }; +} + +/// +/// Describes the complete authorization result for one tool invocation attempt. +/// +/// +/// The dispatcher returns this result before tool execution or a user prompt. +/// The static factory methods enforce the fields that each outcome requires. +/// +internal sealed record ToolAuthorizationDecision +{ + private ToolAuthorizationDecision( + ToolAuthorizationOutcome outcome, + ToolAllowReason? allowReason, + string? denyReason, + ToolApprovalContext? approvalContext, + IReadOnlyList approvalMatches) + { + Outcome = outcome; + AllowReason = allowReason; + DenyReason = denyReason; + ApprovalContext = approvalContext; + ApprovalMatches = approvalMatches; + } + + /// + /// Gets the action that the caller must take for this attempt. + /// + public ToolAuthorizationOutcome Outcome { get; } + + /// + /// Gets the allow rule when is . + /// + public ToolAllowReason? AllowReason { get; } + + /// + /// Gets the stable deny reason when is . + /// + public string? DenyReason { get; } + + /// + /// Gets the prompt data when is . + /// + public ToolApprovalContext? ApprovalContext { get; } + + /// + /// Gets the session or persistent grants that matched this attempt. + /// + /// + /// A prompt decision can contain partial matches for a compound command. + /// An allowed stored-approval decision contains a match for each required candidate. + /// A one-time decision can contain stored matches for part of a compound command. + /// Policy, safe-rule, approval-exempt, and deny decisions contain an empty list. + /// + public IReadOnlyList ApprovalMatches { get; } + + /// + /// Creates an allowed result without stored approval matches. + /// + public static ToolAuthorizationDecision Allow(ToolAllowReason reason) + { + ValidateAllowReason(reason); + return new ToolAuthorizationDecision(ToolAuthorizationOutcome.Allowed, reason, null, null, []); + } + + /// + /// Creates an allowed result with structured approval matches. + /// + public static ToolAuthorizationDecision Allow( + ToolAllowReason reason, + IReadOnlyList approvalMatches) + { + ValidateAllowReason(reason); + ArgumentNullException.ThrowIfNull(approvalMatches); + return new ToolAuthorizationDecision( + ToolAuthorizationOutcome.Allowed, + reason, + null, + null, + [.. approvalMatches]); + } + + /// + /// Creates a hard-deny result. + /// + public static ToolAuthorizationDecision Deny(string reason) + { + ArgumentException.ThrowIfNullOrWhiteSpace(reason); + return new ToolAuthorizationDecision(ToolAuthorizationOutcome.Denied, null, reason, null, []); + } + + /// + /// Creates an approval-request result without existing approval matches. + /// + public static ToolAuthorizationDecision RequiresApproval(ToolApprovalContext context) + { + ArgumentNullException.ThrowIfNull(context); + return new ToolAuthorizationDecision(ToolAuthorizationOutcome.RequiresApproval, null, null, context, []); + } + + /// + /// Creates an approval-request result with partial stored approval matches. + /// + public static ToolAuthorizationDecision RequiresApproval( + ToolApprovalContext context, + IReadOnlyList approvalMatches) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(approvalMatches); + return new ToolAuthorizationDecision( + ToolAuthorizationOutcome.RequiresApproval, + null, + null, + context, + [.. approvalMatches]); + } + + private static void ValidateAllowReason(ToolAllowReason reason) + { + if (!Enum.IsDefined(reason)) + throw new ArgumentOutOfRangeException(nameof(reason), reason, "Unknown tool allow reason."); + } +}