From bf845ed118187309ac3fc31f2a2eb0e610b2207d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 14 Aug 2026 21:37:02 +0000 Subject: [PATCH] refactor: inline shell policy stages --- .../evidence/refactor-reduction-revision.md | 12 + .../Tools/DispatchingToolExecutorTests.cs | 45 ++ .../Tools/ShellPolicyEvaluationTests.cs | 527 +++++------------- .../Tools/ShellPolicyCoordinator.cs | 406 +++++++++++++- .../Tools/ShellPolicyEvaluation.cs | 100 +--- .../Tools/ShellPolicyGrantStages.cs | 149 ----- .../Tools/ShellPolicyInitialStages.cs | 101 ---- .../Tools/ShellPolicyReviewedSafeStages.cs | 103 ---- .../Tools/ShellPolicyTerminalStage.cs | 76 --- 9 files changed, 601 insertions(+), 918 deletions(-) delete mode 100644 src/Netclaw.Actors/Tools/ShellPolicyGrantStages.cs delete mode 100644 src/Netclaw.Actors/Tools/ShellPolicyInitialStages.cs delete mode 100644 src/Netclaw.Actors/Tools/ShellPolicyReviewedSafeStages.cs delete mode 100644 src/Netclaw.Actors/Tools/ShellPolicyTerminalStage.cs diff --git a/openspec/changes/simplify-shell-policy-evaluator/evidence/refactor-reduction-revision.md b/openspec/changes/simplify-shell-policy-evaluator/evidence/refactor-reduction-revision.md index 4d0fa6ab4..fec7aef03 100644 --- a/openspec/changes/simplify-shell-policy-evaluator/evidence/refactor-reduction-revision.md +++ b/openspec/changes/simplify-shell-policy-evaluator/evidence/refactor-reduction-revision.md @@ -30,6 +30,18 @@ The final state must satisfy both conditions: - The original seven files remain below 5,136 lines and 373 control-flow lines. - The complete changed production footprint has fewer lines and control-flow lines than the baseline. +## Direct-stage reduction slice + +The merged corpus commit is `d2186d83e0ce2fe0d51ac67ea029eefa579abca3`. + +This slice removes the delegate pipeline and its stage array. The coordinator now calls the same typed stages in one fixed order. + +The slice removes 117 production lines and three control-flow lines. It also removes 257 stage-test lines for states that production cannot construct. + +The expanded changed-file footprint now uses 7,240 baseline lines and 479 baseline control-flow lines. This set includes files changed after PR #1947. + +The current slice uses 8,665 lines and 530 control-flow lines. The complete reduction gate remains open. + ## Preliminary coverage and risk The audit used `dotnet-coverage` 18.10.0 and `crap4dotnet` 0.1.1. diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs index 28e218cbd..60e43d4ef 100644 --- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs @@ -1690,6 +1690,51 @@ public async Task Authorization_evaluation_denies_duplicate_actor_candidate_id() Assert.Equal("internal_policy_failure", decision.DenyReason); } + [Fact] + public async Task Authorization_evaluation_propagates_preexisting_cancellation_without_actor_contact() + { + var approvalService = GrantEveryShellCandidate(); + var executor = CreateApprovalGatedShellExecutor(approvalService); + var call = new FunctionCallContent( + "call-preexisting-cancellation", + ShellTool.ToolName, + ToolInput.Create("Command", "git status")); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/preexisting-cancellation"), + cancellation.Token)); + + Assert.Equal(0, approvalService.RequestCount); + } + + [Fact] + public async Task Authorization_evaluation_propagates_actor_cancellation_before_an_ordinary_failure() + { + using var cancellation = new CancellationTokenSource(); + var approvalService = new FixedShellApprovalService(_ => + { + cancellation.Cancel(); + throw new InvalidOperationException("actor failed after cancellation"); + }); + var executor = CreateApprovalGatedShellExecutor(approvalService); + var call = new FunctionCallContent( + "call-actor-cancellation", + ShellTool.ToolName, + ToolInput.Create("Command", "git status")); + + await Assert.ThrowsAnyAsync(() => + executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/actor-cancellation"), + cancellation.Token)); + + Assert.Equal(1, approvalService.RequestCount); + } + [Fact] public async Task Authorization_evaluation_denies_mismatched_actor_match() { diff --git a/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvaluationTests.cs b/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvaluationTests.cs index a7f00edee..c08fc2731 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvaluationTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvaluationTests.cs @@ -18,348 +18,15 @@ public sealed class ShellPolicyEvaluationTests { public static bool IsPosix => !OperatingSystem.IsWindows(); - [Fact] - public async Task Pipeline_stops_after_the_first_complete_result() - { - var evaluation = CreateEvaluation(); - var prompt = ToolAuthorizationDecision.RequiresApproval(evaluation.Projection.ApprovalContext); - var visited = new List(); - ShellPolicyStage[] stages = - [ - (_, _) => - { - visited.Add(1); - return ValueTask.FromResult(new ShellPolicyStageResult.Continue()); - }, - (_, _) => - { - visited.Add(2); - return ValueTask.FromResult(new ShellPolicyStageResult.Complete(prompt)); - }, - (_, _) => - { - visited.Add(3); - return ValueTask.FromResult(new ShellPolicyStageResult.Continue()); - } - ]; - - var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); - - Assert.Same(prompt, result.Decision); - Assert.Equal([1, 2], visited); - Assert.Same(prompt, evaluation.TerminalDecision); - Assert.NotNull(evaluation.CompletedTrace); - Assert.Single(evaluation.CompletedTrace.Rows); - } - - [Fact] - public async Task Pipeline_stops_after_a_stage_fault() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var visited = new List(); - ShellPolicyStage[] stages = - [ - (_, _) => - { - visited.Add(1); - return ValueTask.FromResult( - new ShellPolicyStageResult.Fault(ShellPolicyFault.InvalidCoverage)); - }, - (_, _) => - { - visited.Add(2); - return ValueTask.FromResult(new ShellPolicyStageResult.Continue()); - } - ]; - - var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); - - Assert.Equal(ShellPolicyFault.InvalidCoverage, result.Reason); - Assert.Equal([1], visited); - Assert.Equal(ToolAuthorizationOutcome.Denied, evaluation.TerminalDecision?.Outcome); - Assert.Equal(ShellPolicyFault.InvalidCoverage, evaluation.TerminalFault); - } - - [Fact] - public async Task Pipeline_preserves_coverage_trace_when_a_later_stage_throws() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var candidate = Assert.Single(evaluation.Candidates); - ShellPolicyStage[] stages = - [ - (state, _) => ValueTask.FromResult(state.Cover( - candidate, - ShellCoverageKind.Session, - ShellPolicyReason.SessionGrant, - ShellScopeRelation.ThisChat)), - static (_, _) => throw new InvalidOperationException("stage failed") - ]; - - var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); - - Assert.Equal(ShellPolicyFault.StageException, result.Reason); - Assert.NotNull(evaluation.CompletedTrace); - Assert.Collection( - evaluation.CompletedTrace.Rows, - row => Assert.Equal(ShellPolicyTraceStage.StoredGrantMatch, row.Stage), - row => - { - Assert.Equal(ShellPolicyTraceStage.Completion, row.Stage); - Assert.Equal(ShellPolicyTraceOutcome.Deny, row.Outcome); - }); - } - - [Fact] - public async Task Pipeline_propagates_caller_cancellation() - { - var evaluation = CreateEvaluation(); - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); - ShellPolicyStage[] stages = - [ - static (_, _) => ValueTask.FromResult(new ShellPolicyStageResult.Continue()) - ]; - - await Assert.ThrowsAnyAsync(async () => - await ShellPolicyPipeline.RunAsync(evaluation, stages, cancellation.Token)); - - Assert.Null(evaluation.TerminalDecision); - Assert.Null(evaluation.CompletedTrace); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task Pipeline_propagates_cancellation_before_inspecting_stages(bool hasNullStage) - { - var evaluation = CreateEvaluation(); - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); - ShellPolicyStage[] stages = hasNullStage ? [null!] : []; - - await Assert.ThrowsAnyAsync(async () => - await ShellPolicyPipeline.RunAsync(evaluation, stages, cancellation.Token)); - - Assert.Null(evaluation.TerminalDecision); - Assert.Null(evaluation.CompletedTrace); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task Pipeline_propagates_cancellation_set_by_a_stage(bool throwsAfterCancellation) - { - var evaluation = CreateEvaluation(); - using var cancellation = new CancellationTokenSource(); - var prompt = ToolAuthorizationDecision.RequiresApproval(evaluation.Projection.ApprovalContext); - ShellPolicyStage[] stages = - [ - (_, _) => - { - cancellation.Cancel(); - if (throwsAfterCancellation) - throw new InvalidOperationException("stage failed after cancellation"); - - return ValueTask.FromResult( - new ShellPolicyStageResult.Complete(prompt)); - } - ]; - - await Assert.ThrowsAnyAsync(async () => - await ShellPolicyPipeline.RunAsync(evaluation, stages, cancellation.Token)); - - Assert.Null(evaluation.TerminalDecision); - Assert.Null(evaluation.CompletedTrace); - } - - [Fact] - public async Task Pipeline_does_not_invoke_stages_after_a_terminal_decision() - { - var evaluation = CreateEvaluation(); - var prompt = ToolAuthorizationDecision.RequiresApproval(evaluation.Projection.ApprovalContext); - Assert.IsType(evaluation.Complete(prompt)); - var stageVisited = false; - ShellPolicyStage[] stages = - [ - (_, _) => - { - stageVisited = true; - return ValueTask.FromResult(new ShellPolicyStageResult.Continue()); - } - ]; - - var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); - - Assert.Same(prompt, result.Decision); - Assert.False(stageVisited); - } - - [Fact] - public async Task Pipeline_does_not_invoke_stages_after_a_terminal_fault() - { - var evaluation = CreateEvaluation(); - Assert.IsType(evaluation.Fault(ShellPolicyFault.InvalidCoverage)); - var stageVisited = false; - ShellPolicyStage[] stages = - [ - (_, _) => - { - stageVisited = true; - return ValueTask.FromResult(new ShellPolicyStageResult.Continue()); - } - ]; - - var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); - - Assert.Equal(ShellPolicyFault.InvalidCoverage, result.Reason); - Assert.False(stageVisited); - } - - [Fact] - public async Task Pipeline_denies_when_a_stage_completes_then_returns_continue() - { - var evaluation = CreateEvaluation(); - var prompt = ToolAuthorizationDecision.RequiresApproval(evaluation.Projection.ApprovalContext); - ShellPolicyStage[] stages = - [ - (state, _) => - { - Assert.IsType(state.Complete(prompt)); - return ValueTask.FromResult(new ShellPolicyStageResult.Continue()); - } - ]; - - var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); - - Assert.Equal(ShellPolicyFault.InvalidStageResult, result.Reason); - Assert.Equal(ToolAuthorizationOutcome.Denied, evaluation.TerminalDecision?.Outcome); - Assert.Equal("internal_policy_failure", evaluation.TerminalDecision?.DenyReason); - Assert.Single(Assert.IsType(evaluation.CompletedTrace).Rows); - } - - [Fact] - public async Task Pipeline_denies_when_a_stage_completes_with_a_different_result() - { - var evaluation = CreateEvaluation(); - var prompt = ToolAuthorizationDecision.RequiresApproval(evaluation.Projection.ApprovalContext); - var deny = ToolAuthorizationDecision.Deny("shell_references_protected_path"); - ShellPolicyStage[] stages = - [ - (state, _) => - { - Assert.IsType(state.Complete(prompt)); - return ValueTask.FromResult(new ShellPolicyStageResult.Complete(deny)); - } - ]; - - var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); - - Assert.Equal(ShellPolicyFault.InvalidStageResult, result.Reason); - Assert.Equal("internal_policy_failure", evaluation.TerminalDecision?.DenyReason); - } - - [Fact] - public async Task Pipeline_denies_when_a_stage_faults_then_returns_continue() - { - var evaluation = CreateEvaluation(); - ShellPolicyStage[] stages = - [ - (state, _) => - { - Assert.IsType(state.Fault(ShellPolicyFault.InvalidCoverage)); - return ValueTask.FromResult(new ShellPolicyStageResult.Continue()); - } - ]; - - var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); - - Assert.Equal(ShellPolicyFault.InvalidStageResult, result.Reason); - Assert.Equal("internal_policy_failure", evaluation.TerminalDecision?.DenyReason); - } - - [Fact] - public async Task Pipeline_denies_when_a_stage_completes_then_throws() - { - var evaluation = CreateEvaluation(); - var prompt = ToolAuthorizationDecision.RequiresApproval(evaluation.Projection.ApprovalContext); - ShellPolicyStage[] stages = - [ - (state, _) => - { - Assert.IsType(state.Complete(prompt)); - throw new InvalidOperationException("stage failed"); - } - ]; - - var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); - - Assert.Equal(ShellPolicyFault.StageException, result.Reason); - Assert.Equal("internal_policy_failure", evaluation.TerminalDecision?.DenyReason); - } - - [Fact] - public async Task Pipeline_rejects_an_invalid_fault_enum() - { - var evaluation = CreateEvaluation(); - ShellPolicyStage[] stages = - [ - static (_, _) => ValueTask.FromResult( - new ShellPolicyStageResult.Fault((ShellPolicyFault)999)) - ]; - - var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); - - Assert.Equal(ShellPolicyFault.InvalidStageResult, result.Reason); - Assert.Equal(ShellPolicyFault.InvalidStageResult, evaluation.TerminalFault); - Assert.Equal("internal_policy_failure", evaluation.TerminalDecision?.DenyReason); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task Pipeline_rejects_null_stages_and_results_before_later_stages(bool nullStage) - { - var evaluation = CreateEvaluation(); - var laterStageVisited = false; - ShellPolicyStage[] stages = - [ - nullStage - ? null! - : static (_, _) => ValueTask.FromResult(null!), - (_, _) => - { - laterStageVisited = true; - return ValueTask.FromResult(new ShellPolicyStageResult.Continue()); - } - ]; - - var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); - - Assert.Equal(ShellPolicyFault.InvalidStageResult, result.Reason); - Assert.Equal("internal_policy_failure", evaluation.TerminalDecision?.DenyReason); - Assert.False(laterStageVisited); - } - [Fact] public async Task Syntax_stage_prompts_before_a_later_stage_for_untyped_candidates() { var candidate = new ApprovalCandidate("git status", "/work"); var evaluation = CreateEvaluation(candidate); var laterStageVisited = false; - ShellPolicyStage[] stages = + TestStage[] stages = [ - ShellPolicyInitialStages.Syntax(ShellTool.ToolName), + SyntaxStage(ShellTool.ToolName), (_, _) => { laterStageVisited = true; @@ -368,7 +35,7 @@ public async Task Syntax_stage_prompts_before_a_later_stage_for_untyped_candidat ]; var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); + await RunStagesAsync(evaluation, stages, TestContext.Current.CancellationToken)); Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, result.Decision.Outcome); Assert.False(laterStageVisited); @@ -384,9 +51,9 @@ public async Task Syntax_stage_honors_exact_one_time_authority_before_prompting( var evaluation = CreateEvaluationWithExactOneTime(isMessy, candidate); var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync( + await RunStagesAsync( evaluation, - [ShellPolicyInitialStages.Syntax(ShellTool.ToolName)], + [SyntaxStage(ShellTool.ToolName)], TestContext.Current.CancellationToken)); Assert.Equal(ToolAuthorizationOutcome.Allowed, result.Decision.Outcome); @@ -406,9 +73,9 @@ public async Task Syntax_stage_faults_before_a_later_stage_for_invalid_tokens() }; var evaluation = CreateEvaluation(candidate); var laterStageVisited = false; - ShellPolicyStage[] stages = + TestStage[] stages = [ - ShellPolicyInitialStages.Syntax(ShellTool.ToolName), + SyntaxStage(ShellTool.ToolName), (_, _) => { laterStageVisited = true; @@ -417,7 +84,7 @@ public async Task Syntax_stage_faults_before_a_later_stage_for_invalid_tokens() ]; var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); + await RunStagesAsync(evaluation, stages, TestContext.Current.CancellationToken)); Assert.Equal(ShellPolicyFault.InvalidProjection, result.Reason); Assert.False(laterStageVisited); @@ -431,9 +98,9 @@ public async Task Protected_causal_path_stage_denies_before_a_later_stage() "cd /tmp && inspect; head private.log", ["/tmp/private.log"]); var laterStageVisited = false; - ShellPolicyStage[] stages = + TestStage[] stages = [ - ShellPolicyInitialStages.ProtectedCausalPaths(policy), + ProtectedCausalPathsStage(policy), (_, _) => { laterStageVisited = true; @@ -442,7 +109,7 @@ public async Task Protected_causal_path_stage_denies_before_a_later_stage() ]; var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync(evaluation, stages, TestContext.Current.CancellationToken)); + await RunStagesAsync(evaluation, stages, TestContext.Current.CancellationToken)); Assert.Equal(ToolAuthorizationOutcome.Denied, result.Decision.Outcome); Assert.Equal("shell_references_protected_path", result.Decision.DenyReason); @@ -457,9 +124,9 @@ public async Task Protected_causal_path_stage_checks_each_fallback_base() ["/work/result.log"]); var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync( + await RunStagesAsync( evaluation, - [ShellPolicyInitialStages.ProtectedCausalPaths(policy)], + [ProtectedCausalPathsStage(policy)], TestContext.Current.CancellationToken)); Assert.Equal(ToolAuthorizationOutcome.Denied, result.Decision.Outcome); @@ -543,9 +210,9 @@ public async Task Causal_directory_stage_continues_for_eligible_directories() var (evaluation, policy, _) = CreateCausalEvaluation( "cd /tmp && inspect; head result.log"); - var result = await ShellPolicyPipeline.RunAsync( + var result = await RunStagesAsync( evaluation, - [ShellPolicyInitialStages.CausalDirectories(policy, ShellTool.ToolName)], + [CausalDirectoriesStage(policy, ShellTool.ToolName)], TestContext.Current.CancellationToken); Assert.IsType(result); @@ -567,9 +234,9 @@ public async Task Causal_directory_stage_prompts_for_a_symbolic_link_directory() $"cd {alias} && inspect; head result.log"); var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync( + await RunStagesAsync( evaluation, - [ShellPolicyInitialStages.CausalDirectories(policy, ShellTool.ToolName)], + [CausalDirectoriesStage(policy, ShellTool.ToolName)], TestContext.Current.CancellationToken)); Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, result.Decision.Outcome); @@ -601,9 +268,9 @@ public async Task Actor_evidence_stage_applies_one_validated_batch() ]); }); - var result = await ShellPolicyPipeline.RunAsync( + var result = await RunStagesAsync( evaluation, - [ShellPolicyGrantStages.ActorEvidence( + [ActorEvidenceStage( new ShellApprovalEvidenceAdapter(service), (ToolApprovalSessionId)"signalr/shell-policy-actor-stage", TrustAudience.Personal, @@ -634,9 +301,9 @@ public async Task Actor_evidence_stage_rejects_a_malformed_batch_before_later_st new PersistentGrantStoreStatus.Ready(), CandidateMatches: [])); var laterStageVisited = false; - ShellPolicyStage[] stages = + TestStage[] stages = [ - ShellPolicyGrantStages.ActorEvidence( + ActorEvidenceStage( new ShellApprovalEvidenceAdapter(service), (ToolApprovalSessionId)"signalr/shell-policy-invalid-actor-stage", TrustAudience.Personal, @@ -649,7 +316,7 @@ public async Task Actor_evidence_stage_rejects_a_malformed_batch_before_later_st ]; var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync( + await RunStagesAsync( evaluation, stages, TestContext.Current.CancellationToken)); @@ -686,15 +353,15 @@ public async Task Actor_evidence_precedes_approval_exempt_trace_rows() }); var adapter = new ShellApprovalEvidenceAdapter(service); - var result = await ShellPolicyPipeline.RunAsync( + var result = await RunStagesAsync( evaluation, [ - ShellPolicyGrantStages.ActorEvidence( + ActorEvidenceStage( adapter, (ToolApprovalSessionId)"signalr/shell-policy-trace-order", TrustAudience.Personal, new ToolName(ShellTool.ToolName)), - ShellPolicyGrantStages.ApprovalExemptSideEffects(adapter.IsAvailable) + ApprovalExemptSideEffectsStage(adapter.IsAvailable) ], TestContext.Current.CancellationToken); @@ -728,15 +395,15 @@ public async Task Approval_exempt_stage_follows_approval_service_availability( CandidateMatches: [])); var adapter = new ShellApprovalEvidenceAdapter(serviceAvailable ? service : null); - var result = await ShellPolicyPipeline.RunAsync( + var result = await RunStagesAsync( evaluation, [ - ShellPolicyGrantStages.ActorEvidence( + ActorEvidenceStage( adapter, sessionId: null, TrustAudience.Personal, new ToolName(ShellTool.ToolName)), - ShellPolicyGrantStages.ApprovalExemptSideEffects(adapter.IsAvailable) + ApprovalExemptSideEffectsStage(adapter.IsAvailable) ], TestContext.Current.CancellationToken); @@ -761,9 +428,9 @@ public async Task Reviewed_safe_real_scope_stage_requires_interactive_approval( "head"); var candidate = Assert.Single(evaluation.Candidates); - var result = await ShellPolicyPipeline.RunAsync( + var result = await RunStagesAsync( evaluation, - [ShellPolicyReviewedSafeStages.RealScope(policy, context.Invocation)], + [RealScopeStage(policy, context.Invocation)], TestContext.Current.CancellationToken); Assert.IsType(result); @@ -791,9 +458,9 @@ public async Task Reviewed_safe_real_scope_stage_uses_projected_path_facts( interactive: true, phrase); - var result = await ShellPolicyPipeline.RunAsync( + var result = await RunStagesAsync( evaluation, - [ShellPolicyReviewedSafeStages.RealScope(policy, context.Invocation)], + [RealScopeStage(policy, context.Invocation)], TestContext.Current.CancellationToken); Assert.IsType(result); @@ -848,9 +515,9 @@ public async Task Reviewed_safe_real_scope_stage_keeps_declared_windows_roots() @"C:\work", "Get-Content"); - var result = await ShellPolicyPipeline.RunAsync( + var result = await RunStagesAsync( evaluation, - [ShellPolicyReviewedSafeStages.RealScope(policy, context.Invocation)], + [RealScopeStage(policy, context.Invocation)], TestContext.Current.CancellationToken); Assert.IsType(result); @@ -870,9 +537,9 @@ public async Task Reviewed_safe_intent_stage_requires_real_prerequisite_coverage candidate => candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer); Assert.NotEmpty(consumer.IntentPrerequisites); - var beforeCoverage = await ShellPolicyPipeline.RunAsync( + var beforeCoverage = await RunStagesAsync( evaluation, - [ShellPolicyReviewedSafeStages.IntentScope(policy, context.Invocation)], + [IntentScopeStage(policy, context.Invocation)], TestContext.Current.CancellationToken); Assert.IsType(beforeCoverage); @@ -888,11 +555,11 @@ public async Task Reviewed_safe_intent_stage_requires_real_prerequisite_coverage ShellScopeRelation.ThisChat)); } - var afterCoverage = await ShellPolicyPipeline.RunAsync( + var afterCoverage = await RunStagesAsync( evaluation, [ - ShellPolicyReviewedSafeStages.RealScope(policy, context.Invocation), - ShellPolicyReviewedSafeStages.IntentScope(policy, context.Invocation) + RealScopeStage(policy, context.Invocation), + IntentScopeStage(policy, context.Invocation) ], TestContext.Current.CancellationToken); @@ -920,9 +587,9 @@ public async Task Exact_one_time_stage_covers_the_remaining_candidate_set() BashCandidate("git status")); var candidate = Assert.Single(evaluation.Candidates); - var result = await ShellPolicyPipeline.RunAsync( + var result = await RunStagesAsync( evaluation, - [ShellPolicyGrantStages.ExactOneTime( + [ExactOneTimeStage( new ToolName(ShellTool.ToolName), "/work/session")], TestContext.Current.CancellationToken); @@ -954,9 +621,9 @@ public async Task Exact_one_time_and_prompt_share_one_uncovered_context() "/work/session", TrustAudience.Personal); - var result = await ShellPolicyPipeline.RunAsync( + var result = await RunStagesAsync( evaluation, - [ShellPolicyGrantStages.ExactOneTime( + [ExactOneTimeStage( new ToolName("different_tool"), context.SessionDirectory)], TestContext.Current.CancellationToken); @@ -967,9 +634,9 @@ public async Task Exact_one_time_and_prompt_share_one_uncovered_context() var oneTimeContext = evaluation.GetUncoveredApprovalContext(context.SessionDirectory); var terminal = Assert.IsType( - await ShellPolicyPipeline.RunAsync( + await RunStagesAsync( evaluation, - [ShellPolicyTerminalStage.Complete(context)], + [CompleteStage(context)], TestContext.Current.CancellationToken)); Assert.Same(oneTimeContext, terminal.Decision.ApprovalContext); @@ -1228,15 +895,15 @@ public async Task Persistent_store_stage_denies_only_uncovered_candidates( NearMisses: []) ])); - var result = await ShellPolicyPipeline.RunAsync( + var result = await RunStagesAsync( evaluation, [ - ShellPolicyGrantStages.ActorEvidence( + ActorEvidenceStage( new ShellApprovalEvidenceAdapter(service), (ToolApprovalSessionId)"signalr/shell-policy-store-stage", TrustAudience.Personal, new ToolName(ShellTool.ToolName)), - ShellPolicyGrantStages.PersistentStoreAvailability() + PersistentStoreAvailabilityStage() ], TestContext.Current.CancellationToken); @@ -1262,9 +929,9 @@ public async Task Persistent_store_stage_rejects_missing_actor_evidence() var evaluation = CreateEvaluation(BashCandidate("git status")); var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync( + await RunStagesAsync( evaluation, - [ShellPolicyGrantStages.PersistentStoreAvailability()], + [PersistentStoreAvailabilityStage()], TestContext.Current.CancellationToken)); Assert.Equal(ShellPolicyFault.InvalidActorEvidence, result.Reason); @@ -1290,9 +957,9 @@ public async Task Terminal_stage_prompts_with_only_the_uncovered_candidates() TrustAudience.Personal); var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync( + await RunStagesAsync( evaluation, - [ShellPolicyTerminalStage.Complete(context)], + [CompleteStage(context)], TestContext.Current.CancellationToken)); Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, result.Decision.Outcome); @@ -1314,13 +981,13 @@ public async Task Terminal_stage_preserves_one_time_allow_precedence() TrustAudience.Personal); var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync( + await RunStagesAsync( evaluation, [ - ShellPolicyGrantStages.ExactOneTime( + ExactOneTimeStage( new ToolName(ShellTool.ToolName), context.SessionDirectory), - ShellPolicyTerminalStage.Complete(context) + CompleteStage(context) ], TestContext.Current.CancellationToken)); @@ -1348,15 +1015,15 @@ public async Task Terminal_stage_records_a_complete_stored_match_decision() ])); var result = Assert.IsType( - await ShellPolicyPipeline.RunAsync( + await RunStagesAsync( evaluation, [ - ShellPolicyGrantStages.ActorEvidence( + ActorEvidenceStage( new ShellApprovalEvidenceAdapter(service), (ToolApprovalSessionId)"signalr/shell-policy-terminal-stored", TrustAudience.Personal, new ToolName(ShellTool.ToolName)), - ShellPolicyTerminalStage.Complete(context) + CompleteStage(context) ], TestContext.Current.CancellationToken)); @@ -1489,7 +1156,7 @@ public void Invalid_candidate_id_fails_closed() ShellScopeRelation.ThisChat)); Assert.Equal(ShellPolicyFault.InvalidCandidateId, result.Reason); - Assert.Single(evaluation.UncoveredIds); + Assert.Single(evaluation.UncoveredCandidates); Assert.Equal(ToolAuthorizationOutcome.Denied, evaluation.TerminalDecision?.Outcome); } @@ -1653,6 +1320,82 @@ public void Analysis_cannot_attach_to_a_prompt_result() analysis)); } + private delegate ValueTask TestStage( + ShellPolicyEvaluation evaluation, + CancellationToken cancellationToken); + + private static async ValueTask RunStagesAsync( + ShellPolicyEvaluation evaluation, + IReadOnlyList stages, + CancellationToken cancellationToken) + { + foreach (var stage in stages) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = await stage(evaluation, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!evaluation.ApplyStageResult(result)) + return result; + } + + return new ShellPolicyStageResult.Continue(); + } + + private static TestStage SyntaxStage(string toolName) + => (evaluation, _) => ValueTask.FromResult( + ShellPolicyInitialStages.Syntax(evaluation, toolName)); + + private static TestStage ProtectedCausalPathsStage(ToolAccessPolicy policy) + => (evaluation, _) => ValueTask.FromResult( + ShellPolicyInitialStages.ProtectedCausalPaths(evaluation, policy)); + + private static TestStage CausalDirectoriesStage(ToolAccessPolicy policy, string toolName) + => (evaluation, _) => ValueTask.FromResult( + ShellPolicyInitialStages.CausalDirectories(evaluation, policy, toolName)); + + private static TestStage ActorEvidenceStage( + ShellApprovalEvidenceAdapter approvalEvidence, + ToolApprovalSessionId? sessionId, + TrustAudience audience, + ToolName toolName) + => (evaluation, cancellationToken) => ShellPolicyGrantStages.ActorEvidenceAsync( + evaluation, + approvalEvidence, + sessionId, + audience, + toolName, + cancellationToken); + + private static TestStage ApprovalExemptSideEffectsStage(bool approvalEvidenceAvailable) + => (evaluation, _) => ValueTask.FromResult( + ShellPolicyGrantStages.ApprovalExemptSideEffects( + evaluation, + approvalEvidenceAvailable)); + + private static TestStage RealScopeStage( + ToolAccessPolicy policy, + ToolInvocationContext invocation) + => (evaluation, _) => ValueTask.FromResult( + ShellPolicyReviewedSafeStages.RealScope(evaluation, policy, invocation)); + + private static TestStage IntentScopeStage( + ToolAccessPolicy policy, + ToolInvocationContext invocation) + => (evaluation, _) => ValueTask.FromResult( + ShellPolicyReviewedSafeStages.IntentScope(evaluation, policy, invocation)); + + private static TestStage ExactOneTimeStage(ToolName toolName, string? sessionDirectory) + => (evaluation, _) => ValueTask.FromResult( + ShellPolicyGrantStages.ExactOneTime(evaluation, toolName, sessionDirectory)); + + private static TestStage PersistentStoreAvailabilityStage() + => static (evaluation, _) => ValueTask.FromResult( + ShellPolicyGrantStages.PersistentStoreAvailability(evaluation)); + + private static TestStage CompleteStage(ToolExecutionContext context) + => (evaluation, _) => ValueTask.FromResult( + ShellPolicyTerminalStage.Complete(evaluation, context)); + private static ShellPolicyEvaluation CreateEvaluation(params ApprovalCandidate[] candidates) => CreateEvaluation( isMessy: false, diff --git a/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs b/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs index 3ac29cec2..53d336e09 100644 --- a/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs +++ b/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs @@ -144,6 +144,7 @@ private async Task CompleteAsync( } catch (Exception) { + cancellationToken.ThrowIfCancellationRequested(); evaluation.InvalidateStage(ShellPolicyFault.StageException); return CompleteEvaluation(evaluation); } @@ -156,29 +157,60 @@ private async Task CompleteStagesAsync( ShellPolicyEvaluation evaluation, CancellationToken cancellationToken) { - var result = await ShellPolicyPipeline.RunAsync( + bool Continue(ShellPolicyStageResult result) + { + cancellationToken.ThrowIfCancellationRequested(); + return evaluation.ApplyStageResult(result); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (!Continue( + ShellPolicyInitialStages.Syntax(evaluation, toolCall.Name)) + || !Continue( + ShellPolicyInitialStages.ProtectedCausalPaths(evaluation, policy)) + || !Continue( + ShellPolicyInitialStages.CausalDirectories(evaluation, policy, toolCall.Name))) + { + return CompleteEvaluation(evaluation); + } + + var actorEvidence = await ShellPolicyGrantStages.ActorEvidenceAsync( evaluation, - [ - ShellPolicyInitialStages.Syntax(toolCall.Name), - ShellPolicyInitialStages.ProtectedCausalPaths(policy), - ShellPolicyInitialStages.CausalDirectories(policy, toolCall.Name), - ShellPolicyGrantStages.ActorEvidence( - _approvalEvidence, - ToApprovalSessionId(context.SessionId), - context.Audience, - new ToolName(tool.Name)), - ShellPolicyGrantStages.ApprovalExemptSideEffects(_approvalEvidence.IsAvailable), - ShellPolicyReviewedSafeStages.RealScope(policy, context.Invocation), - ShellPolicyReviewedSafeStages.IntentScope(policy, context.Invocation), + _approvalEvidence, + ToApprovalSessionId(context.SessionId), + context.Audience, + new ToolName(tool.Name), + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!Continue(actorEvidence) + || !Continue( + ShellPolicyGrantStages.ApprovalExemptSideEffects( + evaluation, + _approvalEvidence.IsAvailable)) + || !Continue( + ShellPolicyReviewedSafeStages.RealScope( + evaluation, + policy, + context.Invocation)) + || !Continue( + ShellPolicyReviewedSafeStages.IntentScope( + evaluation, + policy, + context.Invocation)) + || !Continue( ShellPolicyGrantStages.ExactOneTime( + evaluation, new ToolName(toolCall.Name), - context.SessionDirectory), - ShellPolicyGrantStages.PersistentStoreAvailability(), - ShellPolicyTerminalStage.Complete(context) - ], - cancellationToken); - if (result is ShellPolicyStageResult.Continue) - evaluation.Fault(ShellPolicyFault.InvalidStageResult); + context.SessionDirectory)) + || !Continue( + ShellPolicyGrantStages.PersistentStoreAvailability(evaluation)) + || !Continue( + ShellPolicyTerminalStage.Complete(evaluation, context))) + { + return CompleteEvaluation(evaluation); + } + + evaluation.InvalidateStage(ShellPolicyFault.InvalidStageResult); return CompleteEvaluation(evaluation); } @@ -208,3 +240,337 @@ private static ToolAuthorizationDecision CompleteEvaluation(ShellPolicyEvaluatio private static ToolApprovalSessionId? ToApprovalSessionId(string? sessionId) => sessionId is null ? null : (ToolApprovalSessionId)sessionId; } + +internal static class ShellPolicyInitialStages +{ + internal static ShellPolicyStageResult Syntax( + ShellPolicyEvaluation evaluation, + string toolName) + { + ArgumentNullException.ThrowIfNull(evaluation); + ArgumentException.ThrowIfNullOrWhiteSpace(toolName); + var projection = evaluation.Projection; + if (projection.ApprovalContext.IsMessy && !projection.HasCausalIntent) + return CreateOneTimeOrPrompt(projection, toolName); + + if (projection.Candidates.Count == 0) + return CreateOneTimeOrPrompt(projection, toolName); + + var expectedShell = projection.Environment.Grammar == ShellGrammar.Bash + ? ApprovalShell.Bash + : ApprovalShell.PowerShell; + if (projection.Candidates.Any(static candidate => + candidate.Candidate.Shell is null + || candidate.Candidate.VerbTokens is null)) + { + return CreateOneTimeOrPrompt(projection, toolName); + } + + if (projection.Candidates.Any(candidate => + candidate.Candidate.Shell != expectedShell + || candidate.Candidate.VerbTokens!.Count == 0 + || candidate.Candidate.VerbTokens.Any(static token => + token.Length == 0 || token.Any(char.IsWhiteSpace)))) + { + return new ShellPolicyStageResult.Fault(ShellPolicyFault.InvalidProjection); + } + + return new ShellPolicyStageResult.Continue(); + } + + internal static ShellPolicyStageResult ProtectedCausalPaths( + ShellPolicyEvaluation evaluation, + ToolAccessPolicy policy) + { + ArgumentNullException.ThrowIfNull(evaluation); + ArgumentNullException.ThrowIfNull(policy); + return evaluation.Candidates.Any(candidate => + candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer + && policy.CausalIntentReferencesProtectedPath( + evaluation.Projection.PathFacts.For(candidate.Id))) + ? new ShellPolicyStageResult.Complete( + ToolAuthorizationDecision.Deny("shell_references_protected_path")) + : new ShellPolicyStageResult.Continue(); + } + + internal static ShellPolicyStageResult CausalDirectories( + ShellPolicyEvaluation evaluation, + ToolAccessPolicy policy, + string toolName) + { + ArgumentNullException.ThrowIfNull(evaluation); + ArgumentNullException.ThrowIfNull(policy); + ArgumentException.ThrowIfNullOrWhiteSpace(toolName); + return evaluation.Candidates.Any(candidate => + candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer + && candidate.IntentDirectory is { } intentDirectory + && !policy.AreCausalIntentDirectoriesEligible( + intentDirectory, + candidate.IntentFallbackDirectories)) + ? CreateOneTimeOrPrompt(evaluation.Projection, toolName) + : new ShellPolicyStageResult.Continue(); + } + + private static ShellPolicyStageResult CreateOneTimeOrPrompt( + ShellPolicyProjection projection, + string toolName) + => projection.HasExactOneTimeApproval(toolName, projection.ApprovalContext) + ? ShellPolicyStageResult.Complete.ExactOneTime( + ToolAuthorizationDecision.Allow(ToolAllowReason.OneTimeApproval)) + : new ShellPolicyStageResult.Complete( + ToolAuthorizationDecision.RequiresApproval(projection.ApprovalContext)); +} + +internal static class ShellPolicyGrantStages +{ + internal static async ValueTask ActorEvidenceAsync( + ShellPolicyEvaluation evaluation, + ShellApprovalEvidenceAdapter approvalEvidence, + ToolApprovalSessionId? sessionId, + TrustAudience audience, + ToolName toolName, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(evaluation); + ArgumentNullException.ThrowIfNull(approvalEvidence); + ArgumentException.ThrowIfNullOrWhiteSpace(toolName.Value); + var projection = evaluation.Projection; + var grantCandidates = projection.GrantCandidates; + var requestCandidates = grantCandidates + .Select(candidate => new ShellGrantCandidate( + candidate.Id, + candidate.Candidate, + projection.ApprovalContext.Cwd)) + .ToArray(); + var actorResult = await approvalEvidence.MatchAsync( + new ShellApprovalMatchRequest( + sessionId, + audience, + toolName, + projection.Environment, + Array.AsReadOnly(requestCandidates)), + projection.ApprovalContext.Cwd, + cancellationToken); + if (!ValidatedShellGrantEvidence.TryCreate( + actorResult, + grantCandidates, + projection.ApprovalContext.Cwd, + out var grantEvidence) + || grantEvidence is null) + { + return new ShellPolicyStageResult.Fault(ShellPolicyFault.InvalidActorEvidence); + } + + return evaluation.ApplyActorEvidence(grantEvidence); + } + + internal static ShellPolicyStageResult ApprovalExemptSideEffects( + ShellPolicyEvaluation evaluation, + bool approvalEvidenceAvailable) + { + ArgumentNullException.ThrowIfNull(evaluation); + if (!approvalEvidenceAvailable) + return new ShellPolicyStageResult.Continue(); + + foreach (var candidate in evaluation.Candidates.Where(static item => + item.Role == ShellPolicyCandidateRole.Ordinary + && ApprovalPatternMatching.IsPureSideEffect(item.Candidate))) + { + var result = evaluation.Cover( + candidate, + ShellCoverageKind.ReviewedSafePolicy, + ShellPolicyReason.ApprovalExemptSideEffect, + ShellScopeRelation.None); + if (result is not ShellPolicyStageResult.Continue) + return result; + } + + return new ShellPolicyStageResult.Continue(); + } + + internal static ShellPolicyStageResult ExactOneTime( + ShellPolicyEvaluation evaluation, + ToolName toolName, + string? sessionDirectory) + { + ArgumentNullException.ThrowIfNull(evaluation); + ArgumentException.ThrowIfNullOrWhiteSpace(toolName.Value); + var uncovered = evaluation.UncoveredCandidates; + if (uncovered.Count == 0) + return new ShellPolicyStageResult.Continue(); + + var remainingContext = evaluation.GetUncoveredApprovalContext(sessionDirectory); + if (!evaluation.Projection.HasExactOneTimeApproval(toolName.Value, remainingContext)) + return new ShellPolicyStageResult.Continue(); + + foreach (var candidate in uncovered) + { + var result = evaluation.Cover( + candidate, + ShellCoverageKind.OneTime, + ShellPolicyReason.OneTimeGrant, + ShellScopeRelation.None); + if (result is not ShellPolicyStageResult.Continue) + return result; + } + + return new ShellPolicyStageResult.Continue(); + } + + internal static ShellPolicyStageResult PersistentStoreAvailability( + ShellPolicyEvaluation evaluation) + { + ArgumentNullException.ThrowIfNull(evaluation); + if (evaluation.GrantEvidence is null) + return new ShellPolicyStageResult.Fault(ShellPolicyFault.InvalidActorEvidence); + + return evaluation.UncoveredCandidates.Count > 0 + && evaluation.GrantEvidence.PersistentStore + is PersistentGrantStoreStatus.Unavailable + ? new ShellPolicyStageResult.Complete( + ToolAuthorizationDecision.Deny("approval_store_unavailable")) + : new ShellPolicyStageResult.Continue(); + } +} + +internal static class ShellPolicyReviewedSafeStages +{ + internal static ShellPolicyStageResult RealScope( + ShellPolicyEvaluation evaluation, + ToolAccessPolicy policy, + ToolInvocationContext invocation) + { + ArgumentNullException.ThrowIfNull(evaluation); + ArgumentNullException.ThrowIfNull(policy); + ArgumentNullException.ThrowIfNull(invocation); + if (!CanUseReviewedSafePolicy(evaluation)) + return new ShellPolicyStageResult.Continue(); + + foreach (var candidate in evaluation.Projection.GrantCandidates.Where(candidate => + candidate.CanUseRealReviewedSafePolicy + && !evaluation.IsCovered(candidate.Id))) + { + if (!policy.IsReviewedSafeCandidate( + candidate.Candidate, + evaluation.Projection.PathFacts.For(candidate.Id), + invocation)) + { + continue; + } + + var result = evaluation.Cover( + candidate, + ShellCoverageKind.ReviewedSafePolicy, + ShellPolicyReason.ReviewedSafePhrase, + ShellScopeRelation.UnderRealRoot); + if (result is not ShellPolicyStageResult.Continue) + return result; + } + + return new ShellPolicyStageResult.Continue(); + } + + internal static ShellPolicyStageResult IntentScope( + ShellPolicyEvaluation evaluation, + ToolAccessPolicy policy, + ToolInvocationContext invocation) + { + ArgumentNullException.ThrowIfNull(evaluation); + ArgumentNullException.ThrowIfNull(policy); + ArgumentNullException.ThrowIfNull(invocation); + if (!CanUseReviewedSafePolicy(evaluation)) + return new ShellPolicyStageResult.Continue(); + + foreach (var candidate in evaluation.Candidates.Where(candidate => + candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer + && !evaluation.IsCovered(candidate.Id))) + { + if (candidate.IntentDirectory is null + || candidate.IntentPrerequisites.Count == 0 + || candidate.IntentPrerequisites.Any(prerequisite => + !evaluation.IsCovered(prerequisite)) + || !policy.IsReviewedSafeIntentCandidate( + candidate.Candidate, + evaluation.Projection.PathFacts.For(candidate.Id), + invocation)) + { + continue; + } + + var result = evaluation.Cover( + candidate, + ShellCoverageKind.ReviewedSafePolicy, + ShellPolicyReason.ReviewedSafePhrase, + ShellScopeRelation.UnderIntentRoot); + if (result is not ShellPolicyStageResult.Continue) + return result; + } + + return new ShellPolicyStageResult.Continue(); + } + + private static bool CanUseReviewedSafePolicy(ShellPolicyEvaluation evaluation) + => evaluation.Projection.RunScope.InteractiveApproval + is InteractiveApprovalCapability.Available; +} + +internal static class ShellPolicyTerminalStage +{ + internal static ShellPolicyStageResult Complete( + ShellPolicyEvaluation evaluation, + ToolExecutionContext context) + { + ArgumentNullException.ThrowIfNull(evaluation); + ArgumentNullException.ThrowIfNull(context); + var projection = evaluation.Projection; + var approvalMatches = evaluation.ApprovalMatches; + var uncovered = evaluation.UncoveredCandidates; + if (uncovered.Count > 0) + { + return evaluation.Complete( + ToolAuthorizationDecision.RequiresApproval( + evaluation.GetUncoveredApprovalContext(context.SessionDirectory), + approvalMatches)); + } + + if (!evaluation.AllCovered) + { + return evaluation.Complete( + ToolAuthorizationDecision.Deny("internal_policy_failure")); + } + + if (evaluation.HasOneTimeCoverage) + { + return evaluation.Complete( + ToolAuthorizationDecision.Allow( + ToolAllowReason.OneTimeApproval, + approvalMatches)); + } + + var grantCandidates = projection.GrantCandidates; + if (approvalMatches.Count > 0) + { + if (approvalMatches.Count == grantCandidates.Count) + { + context.Approval.ApplyDecision( + "PreviouslyApproved", + FormatApprovalMatches(approvalMatches)); + } + + return evaluation.Complete( + ToolAuthorizationDecision.Allow( + ToolAllowReason.StoredApproval, + approvalMatches)); + } + + return evaluation.Complete( + ToolAuthorizationDecision.Allow( + grantCandidates.Count == 0 + ? ToolAllowReason.ApprovalExemptShellCandidates + : ToolAllowReason.SafeVerbInTrustedScope)); + } + + private static string FormatApprovalMatches(IReadOnlyList matches) + => string.Join(", ", matches.Select(match => + $"{match.Pattern} [{match.Source}: {match.Scope}]")); +} diff --git a/src/Netclaw.Actors/Tools/ShellPolicyEvaluation.cs b/src/Netclaw.Actors/Tools/ShellPolicyEvaluation.cs index d03dc15a7..76d1f6acf 100644 --- a/src/Netclaw.Actors/Tools/ShellPolicyEvaluation.cs +++ b/src/Netclaw.Actors/Tools/ShellPolicyEvaluation.cs @@ -121,77 +121,6 @@ internal sealed record Fault(ShellPolicyFault Reason) : ShellPolicyStageResult; } -internal delegate ValueTask ShellPolicyStage( - ShellPolicyEvaluation evaluation, - CancellationToken cancellationToken); - -internal static class ShellPolicyPipeline -{ - internal static async ValueTask RunAsync( - ShellPolicyEvaluation evaluation, - IReadOnlyList stages, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(evaluation); - ArgumentNullException.ThrowIfNull(stages); - - cancellationToken.ThrowIfCancellationRequested(); - if (evaluation.TerminalFault is { } terminalFault) - return new ShellPolicyStageResult.Fault(terminalFault); - - if (evaluation.TerminalDecision is { } terminalDecision) - return new ShellPolicyStageResult.Complete(terminalDecision); - - foreach (var stage in stages) - { - cancellationToken.ThrowIfCancellationRequested(); - if (stage is null) - return evaluation.InvalidateStage(ShellPolicyFault.InvalidStageResult); - - ShellPolicyStageResult? result; - try - { - result = await stage(evaluation, cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception) - { - cancellationToken.ThrowIfCancellationRequested(); - return evaluation.InvalidateStage(ShellPolicyFault.StageException); - } - - cancellationToken.ThrowIfCancellationRequested(); - switch (result) - { - case ShellPolicyStageResult.Continue - when evaluation.TerminalDecision is null: - continue; - case ShellPolicyStageResult.Complete complete - when evaluation.TerminalDecision is null: - return evaluation.Complete( - complete.Decision, - complete.AllowsUncoveredOneTime); - case ShellPolicyStageResult.Complete complete - when ReferenceEquals(evaluation.TerminalDecision, complete.Decision): - return complete; - case ShellPolicyStageResult.Fault fault - when evaluation.TerminalFault == fault.Reason: - return fault; - case ShellPolicyStageResult.Fault fault - when evaluation.TerminalDecision is null: - return evaluation.Fault(fault.Reason); - default: - return evaluation.InvalidateStage(ShellPolicyFault.InvalidStageResult); - } - } - - return new ShellPolicyStageResult.Continue(); - } -} - internal sealed record ShellPolicyAuthorization { internal ShellPolicyAuthorization( @@ -253,12 +182,6 @@ internal ShellPolicyEvaluation(ShellPolicyProjection projection) internal IReadOnlyList Candidates => _candidateView; - internal IReadOnlyList UncoveredIds => Array.AsReadOnly( - _coverage - .Where(static item => item.Kind == ShellCoverageKind.Uncovered) - .Select(static item => item.CandidateId) - .ToArray()); - internal bool AllCovered => _coverage.All(static item => item.Kind is not ShellCoverageKind.Uncovered and not ShellCoverageKind.Denied); @@ -436,6 +359,29 @@ internal ShellPolicyStageResult Complete( return new ShellPolicyStageResult.Complete(decision); } + internal bool ApplyStageResult(ShellPolicyStageResult? result) + { + switch (result) + { + case ShellPolicyStageResult.Continue when _terminalDecision is null: + return true; + case ShellPolicyStageResult.Complete complete when _terminalDecision is null: + Complete(complete.Decision, complete.AllowsUncoveredOneTime); + return false; + case ShellPolicyStageResult.Complete complete + when ReferenceEquals(_terminalDecision, complete.Decision): + return false; + case ShellPolicyStageResult.Fault fault when _terminalFault == fault.Reason: + return false; + case ShellPolicyStageResult.Fault fault when _terminalDecision is null: + Fault(fault.Reason); + return false; + default: + InvalidateStage(ShellPolicyFault.InvalidStageResult); + return false; + } + } + internal ShellPolicyStageResult Fault(ShellPolicyFault reason) { if (!Enum.IsDefined(reason)) diff --git a/src/Netclaw.Actors/Tools/ShellPolicyGrantStages.cs b/src/Netclaw.Actors/Tools/ShellPolicyGrantStages.cs deleted file mode 100644 index 84fc3a559..000000000 --- a/src/Netclaw.Actors/Tools/ShellPolicyGrantStages.cs +++ /dev/null @@ -1,149 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Configuration; -using Netclaw.Security; -using Netclaw.Tools; - -namespace Netclaw.Actors.Tools; - -internal static class ShellPolicyGrantStages -{ - internal static ShellPolicyStage ActorEvidence( - ShellApprovalEvidenceAdapter approvalEvidence, - ToolApprovalSessionId? sessionId, - TrustAudience audience, - ToolName toolName) - { - ArgumentNullException.ThrowIfNull(approvalEvidence); - ArgumentException.ThrowIfNullOrWhiteSpace(toolName.Value); - return (evaluation, cancellationToken) => EvaluateActorEvidenceAsync( - evaluation, - approvalEvidence, - sessionId, - audience, - toolName, - cancellationToken); - } - - internal static ShellPolicyStage ApprovalExemptSideEffects(bool approvalEvidenceAvailable) - => (evaluation, _) => ValueTask.FromResult( - EvaluateApprovalExemptSideEffects(evaluation, approvalEvidenceAvailable)); - - internal static ShellPolicyStage ExactOneTime( - ToolName toolName, - string? sessionDirectory) - { - ArgumentException.ThrowIfNullOrWhiteSpace(toolName.Value); - return (evaluation, _) => ValueTask.FromResult( - EvaluateExactOneTime(evaluation, toolName, sessionDirectory)); - } - - internal static ShellPolicyStage PersistentStoreAvailability() - => static (evaluation, _) => ValueTask.FromResult( - EvaluatePersistentStoreAvailability(evaluation)); - - private static async ValueTask EvaluateActorEvidenceAsync( - ShellPolicyEvaluation evaluation, - ShellApprovalEvidenceAdapter approvalEvidence, - ToolApprovalSessionId? sessionId, - TrustAudience audience, - ToolName toolName, - CancellationToken cancellationToken) - { - var projection = evaluation.Projection; - var grantCandidates = projection.GrantCandidates; - var requestCandidates = grantCandidates - .Select(candidate => new ShellGrantCandidate( - candidate.Id, - candidate.Candidate, - projection.ApprovalContext.Cwd)) - .ToArray(); - var actorResult = await approvalEvidence.MatchAsync( - new ShellApprovalMatchRequest( - sessionId, - audience, - toolName, - projection.Environment, - Array.AsReadOnly(requestCandidates)), - projection.ApprovalContext.Cwd, - cancellationToken); - if (!ValidatedShellGrantEvidence.TryCreate( - actorResult, - grantCandidates, - projection.ApprovalContext.Cwd, - out var grantEvidence) - || grantEvidence is null) - { - return new ShellPolicyStageResult.Fault(ShellPolicyFault.InvalidActorEvidence); - } - - return evaluation.ApplyActorEvidence(grantEvidence); - } - - private static ShellPolicyStageResult EvaluateApprovalExemptSideEffects( - ShellPolicyEvaluation evaluation, - bool approvalEvidenceAvailable) - { - if (!approvalEvidenceAvailable) - return new ShellPolicyStageResult.Continue(); - - foreach (var candidate in evaluation.Candidates.Where(static item => - item.Role == ShellPolicyCandidateRole.Ordinary - && ApprovalPatternMatching.IsPureSideEffect(item.Candidate))) - { - var result = evaluation.Cover( - candidate, - ShellCoverageKind.ReviewedSafePolicy, - ShellPolicyReason.ApprovalExemptSideEffect, - ShellScopeRelation.None); - if (result is not ShellPolicyStageResult.Continue) - return result; - } - - return new ShellPolicyStageResult.Continue(); - } - - private static ShellPolicyStageResult EvaluateExactOneTime( - ShellPolicyEvaluation evaluation, - ToolName toolName, - string? sessionDirectory) - { - var uncovered = evaluation.UncoveredCandidates; - if (uncovered.Count == 0) - return new ShellPolicyStageResult.Continue(); - - var remainingContext = evaluation.GetUncoveredApprovalContext(sessionDirectory); - if (!evaluation.Projection.HasExactOneTimeApproval(toolName.Value, remainingContext)) - return new ShellPolicyStageResult.Continue(); - - foreach (var candidate in uncovered) - { - var result = evaluation.Cover( - candidate, - ShellCoverageKind.OneTime, - ShellPolicyReason.OneTimeGrant, - ShellScopeRelation.None); - if (result is not ShellPolicyStageResult.Continue) - return result; - } - - return new ShellPolicyStageResult.Continue(); - } - - private static ShellPolicyStageResult EvaluatePersistentStoreAvailability( - ShellPolicyEvaluation evaluation) - { - if (evaluation.GrantEvidence is null) - return new ShellPolicyStageResult.Fault(ShellPolicyFault.InvalidActorEvidence); - - return evaluation.UncoveredCandidates.Count > 0 - && evaluation.GrantEvidence.PersistentStore - is PersistentGrantStoreStatus.Unavailable - ? new ShellPolicyStageResult.Complete( - ToolAuthorizationDecision.Deny("approval_store_unavailable")) - : new ShellPolicyStageResult.Continue(); - } -} diff --git a/src/Netclaw.Actors/Tools/ShellPolicyInitialStages.cs b/src/Netclaw.Actors/Tools/ShellPolicyInitialStages.cs deleted file mode 100644 index 35a03d133..000000000 --- a/src/Netclaw.Actors/Tools/ShellPolicyInitialStages.cs +++ /dev/null @@ -1,101 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Configuration; -using Netclaw.Security; -using Netclaw.Tools; - -namespace Netclaw.Actors.Tools; - -internal static class ShellPolicyInitialStages -{ - internal static ShellPolicyStage Syntax(string toolName) - { - ArgumentException.ThrowIfNullOrWhiteSpace(toolName); - return (evaluation, _) => ValueTask.FromResult(EvaluateSyntax(evaluation, toolName)); - } - - internal static ShellPolicyStage ProtectedCausalPaths(ToolAccessPolicy policy) - { - ArgumentNullException.ThrowIfNull(policy); - return (evaluation, _) => ValueTask.FromResult(EvaluateProtectedCausalPaths(evaluation, policy)); - } - - internal static ShellPolicyStage CausalDirectories( - ToolAccessPolicy policy, - string toolName) - { - ArgumentNullException.ThrowIfNull(policy); - ArgumentException.ThrowIfNullOrWhiteSpace(toolName); - return (evaluation, _) => ValueTask.FromResult( - EvaluateCausalDirectories(evaluation, policy, toolName)); - } - - private static ShellPolicyStageResult EvaluateSyntax( - ShellPolicyEvaluation evaluation, - string toolName) - { - var projection = evaluation.Projection; - if (projection.ApprovalContext.IsMessy && !projection.HasCausalIntent) - return CreateOneTimeOrPrompt(projection, toolName); - - if (projection.Candidates.Count == 0) - return CreateOneTimeOrPrompt(projection, toolName); - - var expectedShell = projection.Environment.Grammar == ShellGrammar.Bash - ? ApprovalShell.Bash - : ApprovalShell.PowerShell; - if (projection.Candidates.Any(static candidate => - candidate.Candidate.Shell is null - || candidate.Candidate.VerbTokens is null)) - { - return CreateOneTimeOrPrompt(projection, toolName); - } - - if (projection.Candidates.Any(candidate => - candidate.Candidate.Shell != expectedShell - || candidate.Candidate.VerbTokens!.Count == 0 - || candidate.Candidate.VerbTokens.Any(static token => - token.Length == 0 || token.Any(char.IsWhiteSpace)))) - { - return new ShellPolicyStageResult.Fault(ShellPolicyFault.InvalidProjection); - } - - return new ShellPolicyStageResult.Continue(); - } - - private static ShellPolicyStageResult EvaluateProtectedCausalPaths( - ShellPolicyEvaluation evaluation, - ToolAccessPolicy policy) - => evaluation.Candidates.Any(candidate => - candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer - && policy.CausalIntentReferencesProtectedPath( - evaluation.Projection.PathFacts.For(candidate.Id))) - ? new ShellPolicyStageResult.Complete( - ToolAuthorizationDecision.Deny("shell_references_protected_path")) - : new ShellPolicyStageResult.Continue(); - - private static ShellPolicyStageResult EvaluateCausalDirectories( - ShellPolicyEvaluation evaluation, - ToolAccessPolicy policy, - string toolName) - => evaluation.Candidates.Any(candidate => - candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer - && candidate.IntentDirectory is { } intentDirectory - && !policy.AreCausalIntentDirectoriesEligible( - intentDirectory, - candidate.IntentFallbackDirectories)) - ? CreateOneTimeOrPrompt(evaluation.Projection, toolName) - : new ShellPolicyStageResult.Continue(); - - private static ShellPolicyStageResult CreateOneTimeOrPrompt( - ShellPolicyProjection projection, - string toolName) - => projection.HasExactOneTimeApproval(toolName, projection.ApprovalContext) - ? ShellPolicyStageResult.Complete.ExactOneTime( - ToolAuthorizationDecision.Allow(ToolAllowReason.OneTimeApproval)) - : new ShellPolicyStageResult.Complete( - ToolAuthorizationDecision.RequiresApproval(projection.ApprovalContext)); -} diff --git a/src/Netclaw.Actors/Tools/ShellPolicyReviewedSafeStages.cs b/src/Netclaw.Actors/Tools/ShellPolicyReviewedSafeStages.cs deleted file mode 100644 index bee9fd9c0..000000000 --- a/src/Netclaw.Actors/Tools/ShellPolicyReviewedSafeStages.cs +++ /dev/null @@ -1,103 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Tools; - -namespace Netclaw.Actors.Tools; - -internal static class ShellPolicyReviewedSafeStages -{ - internal static ShellPolicyStage RealScope( - ToolAccessPolicy policy, - ToolInvocationContext invocation) - { - ArgumentNullException.ThrowIfNull(policy); - ArgumentNullException.ThrowIfNull(invocation); - return (evaluation, _) => ValueTask.FromResult( - EvaluateRealScope(evaluation, policy, invocation)); - } - - internal static ShellPolicyStage IntentScope( - ToolAccessPolicy policy, - ToolInvocationContext invocation) - { - ArgumentNullException.ThrowIfNull(policy); - ArgumentNullException.ThrowIfNull(invocation); - return (evaluation, _) => ValueTask.FromResult( - EvaluateIntentScope(evaluation, policy, invocation)); - } - - private static ShellPolicyStageResult EvaluateRealScope( - ShellPolicyEvaluation evaluation, - ToolAccessPolicy policy, - ToolInvocationContext invocation) - { - if (!CanUseReviewedSafePolicy(evaluation)) - return new ShellPolicyStageResult.Continue(); - - foreach (var candidate in evaluation.Projection.GrantCandidates.Where(candidate => - candidate.CanUseRealReviewedSafePolicy - && !evaluation.IsCovered(candidate.Id))) - { - if (!policy.IsReviewedSafeCandidate( - candidate.Candidate, - evaluation.Projection.PathFacts.For(candidate.Id), - invocation)) - { - continue; - } - - var result = evaluation.Cover( - candidate, - ShellCoverageKind.ReviewedSafePolicy, - ShellPolicyReason.ReviewedSafePhrase, - ShellScopeRelation.UnderRealRoot); - if (result is not ShellPolicyStageResult.Continue) - return result; - } - - return new ShellPolicyStageResult.Continue(); - } - - private static ShellPolicyStageResult EvaluateIntentScope( - ShellPolicyEvaluation evaluation, - ToolAccessPolicy policy, - ToolInvocationContext invocation) - { - if (!CanUseReviewedSafePolicy(evaluation)) - return new ShellPolicyStageResult.Continue(); - - foreach (var candidate in evaluation.Candidates.Where(candidate => - candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer - && !evaluation.IsCovered(candidate.Id))) - { - if (candidate.IntentDirectory is null - || candidate.IntentPrerequisites.Count == 0 - || candidate.IntentPrerequisites.Any(prerequisite => - !evaluation.IsCovered(prerequisite)) - || !policy.IsReviewedSafeIntentCandidate( - candidate.Candidate, - evaluation.Projection.PathFacts.For(candidate.Id), - invocation)) - { - continue; - } - - var result = evaluation.Cover( - candidate, - ShellCoverageKind.ReviewedSafePolicy, - ShellPolicyReason.ReviewedSafePhrase, - ShellScopeRelation.UnderIntentRoot); - if (result is not ShellPolicyStageResult.Continue) - return result; - } - - return new ShellPolicyStageResult.Continue(); - } - - private static bool CanUseReviewedSafePolicy(ShellPolicyEvaluation evaluation) - => evaluation.Projection.RunScope.InteractiveApproval - is InteractiveApprovalCapability.Available; -} diff --git a/src/Netclaw.Actors/Tools/ShellPolicyTerminalStage.cs b/src/Netclaw.Actors/Tools/ShellPolicyTerminalStage.cs deleted file mode 100644 index 27a326c8b..000000000 --- a/src/Netclaw.Actors/Tools/ShellPolicyTerminalStage.cs +++ /dev/null @@ -1,76 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Configuration; -using Netclaw.Security; -using Netclaw.Tools; - -namespace Netclaw.Actors.Tools; - -internal static class ShellPolicyTerminalStage -{ - internal static ShellPolicyStage Complete(ToolExecutionContext context) - { - ArgumentNullException.ThrowIfNull(context); - return (evaluation, _) => ValueTask.FromResult( - CompleteEvaluation(evaluation, context)); - } - - private static ShellPolicyStageResult CompleteEvaluation( - ShellPolicyEvaluation evaluation, - ToolExecutionContext context) - { - var projection = evaluation.Projection; - var approvalMatches = evaluation.ApprovalMatches; - var uncovered = evaluation.UncoveredCandidates; - if (uncovered.Count > 0) - { - return evaluation.Complete( - ToolAuthorizationDecision.RequiresApproval( - evaluation.GetUncoveredApprovalContext(context.SessionDirectory), - approvalMatches)); - } - - if (!evaluation.AllCovered) - { - return evaluation.Complete( - ToolAuthorizationDecision.Deny("internal_policy_failure")); - } - - if (evaluation.HasOneTimeCoverage) - { - return evaluation.Complete( - ToolAuthorizationDecision.Allow( - ToolAllowReason.OneTimeApproval, - approvalMatches)); - } - - var grantCandidates = projection.GrantCandidates; - if (approvalMatches.Count > 0) - { - if (approvalMatches.Count == grantCandidates.Count) - { - context.Approval.ApplyDecision( - "PreviouslyApproved", - FormatApprovalMatches(approvalMatches)); - } - - return evaluation.Complete( - ToolAuthorizationDecision.Allow( - ToolAllowReason.StoredApproval, - approvalMatches)); - } - - return evaluation.Complete( - ToolAuthorizationDecision.Allow( - grantCandidates.Count == 0 - ? ToolAllowReason.ApprovalExemptShellCandidates - : ToolAllowReason.SafeVerbInTrustedScope)); - } - - private static string FormatApprovalMatches(IReadOnlyList matches) - => string.Join(", ", matches.Select(match => - $"{match.Pattern} [{match.Source}: {match.Scope}]")); -}