From b46007f069328c97dfdd039064ad589e84bfa856 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 15 Aug 2026 00:21:56 +0000 Subject: [PATCH] Collapse shell policy phase machinery --- .../simplify-shell-policy-evaluator/design.md | 68 +- .../evidence/refactor-reduction-revision.md | 17 + .../proposal.md | 6 +- .../spec.md | 26 +- .../simplify-shell-policy-evaluator/tasks.md | 2 + .../Tools/DispatchingToolExecutorTests.cs | 31 + .../Tools/ShellPolicyEvaluationTests.cs | 1664 ----------------- .../Tools/ShellPolicyPathFactsTests.cs | 292 +++ .../Tools/ShellPolicyCoordinator.cs | 375 +--- .../Tools/ShellPolicyEvaluation.cs | 115 +- 10 files changed, 505 insertions(+), 2091 deletions(-) delete mode 100644 src/Netclaw.Actors.Tests/Tools/ShellPolicyEvaluationTests.cs create mode 100644 src/Netclaw.Actors.Tests/Tools/ShellPolicyPathFactsTests.cs diff --git a/openspec/changes/simplify-shell-policy-evaluator/design.md b/openspec/changes/simplify-shell-policy-evaluator/design.md index 5fe6acd10..be1e7f1c7 100644 --- a/openspec/changes/simplify-shell-policy-evaluator/design.md +++ b/openspec/changes/simplify-shell-policy-evaluator/design.md @@ -39,10 +39,10 @@ The exact D-case fixtures, 12 adversarial cases, 11 live cases, and the full pol **Goals:** - Replace the context analysis cache with one explicit preflight result. -- Give each ordered policy stage one clear owner. -- Keep one call-local state for candidates, coverage, evidence, trace facts, and terminal status. +- Keep the ordered policy phases in one direct evaluator. +- Keep one call-local state for candidates, coverage, evidence, and trace facts. - Validate actor output once before any grant coverage applies. -- Compute parser-derived path facts once and reuse them across policy stages. +- Compute parser-derived path facts once and reuse them across policy phases. - Reduce total production lines and control-flow lines below the measured baseline. - Preserve all public, wire, persistence, prompt, trace, and operator contracts. - Keep every unknown or invalid internal state fail-closed. @@ -115,42 +115,32 @@ Add one internal `ShellPolicyEvaluation` class. It exists for one authorization It owns: -- the preflight result and `ShellPolicyProjection`; +- the `ShellPolicyProjection`; - candidate IDs and immutable candidate facts; - one coverage slot per candidate; - validated actor evidence; - approval matches; - persistent-store status; -- the trace builder; -- the terminal decision, when present. +- the trace builder. -Only methods on this type may change coverage. A stage cannot replace candidate facts or IDs. +Only methods on this type may change coverage. The evaluator cannot replace candidate facts or IDs. Why: - Mutation stays local and auditable. -- Stages no longer pass parallel lists and maps. +- Policy phases no longer pass parallel lists and maps. - Coverage and trace updates can occur through one atomic method. -- The class avoids a new immutable array allocation after each stage. +- The class avoids a new immutable array allocation after each phase. -Alternative: return a new immutable state after every stage. Rejected because it adds allocation and code without stronger call-local safety. +Alternative: return a new immutable state after every phase. Rejected because it adds allocation and code without stronger call-local safety. -### 3. Ordered stages return one closed outcome +### 3. One direct evaluator owns the fixed order -Each stage changes state only through `ShellPolicyEvaluation`, then returns a closed outcome: +The coordinator executes each policy phase in one method. A terminal decision returns immediately. Coverage changes still pass through `ShellPolicyEvaluation`, while the decision remains a local return value. -```csharp -internal enum ShellPolicyStageOutcome -{ - Invalid = 0, - Continue = 1, - Complete = 2, -} -``` - -Coverage, terminal decisions, and typed faults remain owned by the evaluation state. The coordinator verifies that `Continue` has no terminal decision and `Complete` has one. An invalid enum or mismatched outcome fails closed. +Unexpected exceptions enter one catch boundary. That boundary preserves accumulated trace rows, appends the terminal internal-failure row, and denies. Caller cancellation still propagates. -The early syntax and causal helpers are the sole callers that may complete an uncovered exact one-time allow. +The early syntax and causal phases are the sole paths that may complete an uncovered exact one-time allow. That marker requires all of these facts: @@ -161,7 +151,7 @@ That marker requires all of these facts: No session, persistent, reviewed-safe, or other allow reason may bypass candidate coverage. -The pipeline invokes stages in this order: +The evaluator invokes phases in this order: 1. syntax and candidate validation; 2. protected real and fallback paths; @@ -176,7 +166,7 @@ The pipeline invokes stages in this order: Actor evidence stays before approval-exempt trace rows to preserve the frozen trace order. Pure side effects never enter the actor request. -Synchronous preflight keeps its current order before these stages: +Synchronous preflight keeps its current order before these phases: 1. parse validation; 2. hard deny; @@ -185,16 +175,16 @@ Synchronous preflight keeps its current order before these stages: 5. candidate construction; 6. noninteractive trust zones. -A terminal outcome stops the pipeline. A later stage cannot revise an earlier deny or prompt. +A terminal outcome returns from the evaluator. A later phase cannot revise an earlier deny or prompt. Why: -- Order becomes data, not incidental control flow. -- Each stage has a narrow test surface. +- Order is visible in one compact method. +- Behavior tests exercise the real coordinator instead of isolated stage machinery. - Terminal precedence is visible. - Internal faults map through one fail-closed path. -Alternative: retain one large method with regions. Rejected because regions do not enforce ownership or terminal precedence. +The earlier stage-result hierarchy was removed. It added terminal state, fault state, transition validation, factories, and 1,664 lines of implementation-shaped tests without changing observable policy. ### 4. Actor evidence has one validation boundary @@ -211,7 +201,7 @@ The factory validates: - near-miss count and enum values; - the unavailable-store restrictions. -No grant enters coverage before this factory succeeds. The coordinator receives only validated evidence or a typed fault. +No grant enters coverage before this factory succeeds. The coordinator receives validated evidence or fails closed. Why: @@ -246,7 +236,7 @@ Known paths use an internal canonical absolute value with an explicit `ShellPath The authorization preflight keeps its single raw `CommandReferencesDeniedPath(analysis)` defense scan. `ShellTool` also retains both execution-time checks. -Policy stages consume these facts through `ToolPathPolicy` and reviewed-safe policy. They do not rescan command text. +Policy phases consume these facts through `ToolPathPolicy` and reviewed-safe policy. They do not rescan command text. Temporary-scope correction remains unchanged before projection. Projection may reuse only its existing captured temporary-alias predicate for canonical path resolution. @@ -274,7 +264,7 @@ This method owns: - causal full-context retention; - exact one-time key input. -The one-time stage and prompt stage call the same method. They cannot derive different candidate sets. +The one-time and prompt phases call the same method. They cannot derive different candidate sets. Alternative: keep two calls to `NarrowShellApprovalContext`. Rejected because future edits can create one-time and prompt drift. @@ -282,7 +272,7 @@ Alternative: keep two calls to `NarrowShellApprovalContext`. Rejected because fu Coverage changes will add their trace row through the same state method. Terminal completion will append exactly one completion row. -The trace builder remains bounded and redacted. Stages cannot write raw commands, arguments, paths, session values, or secrets. +The trace builder remains bounded and redacted. Policy phases cannot write raw commands, arguments, paths, session values, or secrets. Why: @@ -290,7 +280,7 @@ Why: - Tests can compare state transitions with trace rows. - Redaction remains centralized. -Alternative: let each stage write trace rows directly. Rejected because coverage and trace can diverge. +Alternative: let each phase write trace rows directly. Rejected because coverage and trace can diverge. ### 8. Compatibility code remains an isolated adapter @@ -349,7 +339,7 @@ The compatibility adapter is process-local. It creates no durable state. | Failure | Required result | | --- | --- | | Parser or projection fault | `internal_policy_failure` deny | -| Invalid stage transition | `internal_policy_failure` deny | +| Invalid call-local invariant | `internal_policy_failure` deny | | Invalid actor result | `internal_policy_failure` deny | | Required persistent state unavailable | `approval_store_unavailable` deny | | Expected unresolved shell input | one-time or deny prompt only | @@ -361,9 +351,9 @@ No failure path creates session or persistent authority. No rollback needs data ## Risks / Trade-offs -- **Risk: stage extraction changes precedence** → Lock each terminal overlap with exact matrix cases before code moves. +- **Risk: direct evaluation changes precedence** → Lock each terminal overlap with exact matrix cases before code moves. - **Risk: a new state class hides mutation** → Keep mutation methods narrow and expose immutable candidate views. -- **Risk: metric goals reward compressed code** → Require readable stages, corpus parity, and adversarial review. +- **Risk: metric goals reward compressed code** → Require readable phases, corpus parity, and adversarial review. - **Risk: the compatibility adapter survives indefinitely** → Add a removal inventory and make new callers depend on typed evidence. - **Risk: path facts lose source provenance** → Carry parser occurrence identity only inside call-local projection types. - **Risk: a broad slice becomes hard to review** → Deliver dependency-ordered slices with full parity after each slice. @@ -371,10 +361,10 @@ No failure path creates session or persistent authority. No rollback needs data ## Migration Plan 1. Merge the live corpus before production refactor code. -2. Add stage-state types and parity tests with no route changes. +2. Add call-local state and parity tests with no route changes. 3. Replace the context analysis cache with explicit preflight data. 4. Move actor-result validation behind the typed evidence boundary. -5. Route reviewed-safe, one-time, store, and completion logic through stages. +5. Route reviewed-safe, one-time, store, and completion logic through one ordered evaluator. 6. Consolidate path and prompt-context helpers. 7. Remove dead compatibility branches and duplicate helpers. 8. Run full local, Linux, macOS, and native Windows gates after each production slice. 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 59cd6e055..4b1e701d5 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 @@ -102,6 +102,23 @@ The current footprint uses 9,947 lines and 650 control-flow lines. The gap is 97 The complete reduction gate remains open. +## Direct evaluation slice + +This slice removes the stage-outcome hierarchy, terminal-state machine, +typed stage faults, and separate stage-owner classes. One direct coordinator +method keeps the same ten phases in the same order. Coverage and trace rows +still change atomically through the call-local evaluation state. + +The slice removes 254 production lines and 16 control-flow lines. It replaces +the 1,664-line isolated-stage test file with 295 lines of path-fact and atomic +state tests. It also adds one end-to-end cancellation regression. The actor, +disposition, fixture, evidence, recovery, and executor suites exercise the real +coordinator path. + +The complete changed footprint now uses 9,693 lines and 634 control-flow +lines. The frozen baseline uses 8,972 lines and 635 control-flow lines. The +control-flow gate now passes. The line gate remains open by 721 lines. + ## Preliminary coverage and risk The audit used `dotnet-coverage` 18.10.0 and `crap4dotnet` 0.1.1. diff --git a/openspec/changes/simplify-shell-policy-evaluator/proposal.md b/openspec/changes/simplify-shell-policy-evaluator/proposal.md index 6e145f54d..52f76cc4d 100644 --- a/openspec/changes/simplify-shell-policy-evaluator/proposal.md +++ b/openspec/changes/simplify-shell-policy-evaluator/proposal.md @@ -7,7 +7,7 @@ The live corpus now supplies a stable contract for a behavior-compatible refacto ## What Changes - Introduce one typed evaluation state for candidates, coverage, actor evidence, trace facts, and the persistent-store result. -- Give each ordered policy stage one input and one typed result. +- Make one direct evaluator own the documented policy order. - Move actor-result validation behind one protocol boundary. - Consolidate repeated prompt-context, path-fact, coverage, and terminal-decision logic. - Shrink shell-specific branches inside `ToolAccessPolicy` and `ShellApprovalMatcher`. @@ -35,7 +35,7 @@ Out of scope: ### New Capabilities -- `shell-policy-evaluator-architecture`: Defines the typed stage model, ownership boundaries, equivalence contract, and fail-closed internal protocol. +- `shell-policy-evaluator-architecture`: Defines the direct evaluator, ownership boundaries, equivalence contract, and fail-closed internal protocol. ### Modified Capabilities @@ -45,6 +45,6 @@ None. The refactor preserves current `tool-approval-gates` behavior. The change affects internal code in `Netclaw.Actors` and `Netclaw.Security`. Public tool APIs, persisted events, configuration, approval entries, prompts, and traces retain their current contracts. -Security impact is neutral by design. Unknown facts, invalid stage results, internal faults, protected paths, and unavailable required authority remain terminal deny or prompt under the current contract. +Security impact is neutral by design. Unknown facts, invalid call-local invariants, internal faults, protected paths, and unavailable required authority remain terminal deny or prompt under the current contract. Operational impact is limited to ordinary binary rollout. No configuration edit, approval-store reset, database migration, or session migration is required. diff --git a/openspec/changes/simplify-shell-policy-evaluator/specs/shell-policy-evaluator-architecture/spec.md b/openspec/changes/simplify-shell-policy-evaluator/specs/shell-policy-evaluator-architecture/spec.md index b36a4b18d..633a5c097 100644 --- a/openspec/changes/simplify-shell-policy-evaluator/specs/shell-policy-evaluator-architecture/spec.md +++ b/openspec/changes/simplify-shell-policy-evaluator/specs/shell-policy-evaluator-architecture/spec.md @@ -2,7 +2,7 @@ ### Requirement: Shell policy uses one explicit evaluation state -The system SHALL use one call-local shell policy state for projected candidates, coverage, grant evidence, trace facts, and terminal status. +The system SHALL use one call-local shell policy state for projected candidates, coverage, grant evidence, and trace facts. The state SHALL preserve candidate identity and order for the complete authorization call. No state instance SHALL cross an actor, persistence, or session boundary. @@ -14,9 +14,9 @@ The state SHALL preserve candidate identity and order for the complete authoriza #### Scenario: Candidate identity cannot change -- **WHEN** any stage returns a candidate ID or fact that differs from projection +- **WHEN** any policy phase observes a candidate ID or fact that differs from projection - **THEN** policy SHALL deny with `internal_policy_failure` -- **AND** no later stage SHALL apply authority +- **AND** no later phase SHALL apply authority #### Scenario: Allowed analysis reaches execution @@ -24,7 +24,7 @@ The state SHALL preserve candidate identity and order for the complete authoriza - **THEN** the executor SHALL receive the exact analysis that policy authorized - **AND** no analysis SHALL pass through a context cache -#### Scenario: Preflight allows without completion stages +#### Scenario: Preflight allows without asynchronous completion - **WHEN** preflight allows a parsed shell call through Auto mode or another terminal rule - **THEN** its terminal result SHALL carry the exact authorized analysis @@ -36,9 +36,9 @@ The state SHALL preserve candidate identity and order for the complete authoriza - **THEN** policy SHALL return the current decision - **AND** policy SHALL retain no analysis for a later call -### Requirement: Shell policy stages have one fixed order +### Requirement: Shell policy phases have one fixed order -The system SHALL execute synchronous preflight and asynchronous completion stages in the documented order. A terminal stage outcome SHALL stop all later policy stages. +The system SHALL execute synchronous preflight and asynchronous completion phases in the documented order. A terminal decision SHALL stop all later policy phases. #### Scenario: Protected path precedes grant and safe policy @@ -51,9 +51,9 @@ The system SHALL execute synchronous preflight and asynchronous completion stage - **THEN** policy SHALL preserve the current allow result - **AND** policy SHALL deny when an uncovered candidate still depends on persistent state -#### Scenario: Invalid stage result fails closed +#### Scenario: Invalid call-local invariant fails closed -- **WHEN** a stage returns an unknown result, invalid enum, or impossible transition +- **WHEN** evaluation observes an invalid enum, changed candidate, duplicate coverage, or impossible terminal decision - **THEN** policy SHALL deny with `internal_policy_failure` - **AND** policy SHALL not open an approval prompt @@ -85,7 +85,7 @@ Netclaw policy SHALL consume those facts without an executable-private command p - **WHEN** projection contains exact or finite filesystem facts - **THEN** policy SHALL evaluate each fact through the current path rules -- **AND** later stages SHALL reuse the projected result without command-text scans +- **AND** later phases SHALL reuse the projected result without command-text scans #### Scenario: Path facts retain their policy meaning @@ -100,7 +100,7 @@ Netclaw policy SHALL consume those facts without an executable-private command p - **THEN** reviewed-safe coverage SHALL remain unavailable - **AND** the unknown domain SHALL NOT become a protected-path match - **WHEN** an exact or finite causal value cannot resolve against an intent or fallback scope -- **THEN** the causal protected-path stage SHALL retain its current deny outcome +- **THEN** the causal protected-path phase SHALL retain its current deny outcome #### Scenario: Redirect facts preserve their exact boundary @@ -154,7 +154,7 @@ Each coverage change SHALL add its bounded trace fact through the same state ope #### Scenario: Trace data remains redacted -- **WHEN** any stage emits trace evidence +- **WHEN** any policy phase emits trace evidence - **THEN** trace data SHALL exclude raw commands, arguments, paths, prompts, session values, and secrets ### Requirement: Refactor preserves observable policy behavior @@ -206,9 +206,9 @@ It SHALL not add a public API, durable schema, command parser, or duplicate poli The system SHALL map unexpected internal faults to `internal_policy_failure`. Caller cancellation SHALL still propagate without conversion to a policy result. -#### Scenario: Stage throws an internal exception +#### Scenario: Evaluation throws an internal exception -- **WHEN** a policy stage throws outside caller cancellation +- **WHEN** a policy phase throws outside caller cancellation - **THEN** the call SHALL deny with `internal_policy_failure` - **AND** the failure SHALL create no approval authority diff --git a/openspec/changes/simplify-shell-policy-evaluator/tasks.md b/openspec/changes/simplify-shell-policy-evaluator/tasks.md index 61b3dbac9..0540fdfa1 100644 --- a/openspec/changes/simplify-shell-policy-evaluator/tasks.md +++ b/openspec/changes/simplify-shell-policy-evaluator/tasks.md @@ -66,6 +66,8 @@ - [x] 7.7 Audit the complete changed production footprint and revise the reduction gate when file moves hide growth. - [ ] 7.8 Remove the displaced production lines and control flow until the complete footprint is below baseline. - [ ] 7.9 Run exact parity and adversarial review after each additional reduction slice. +- [x] 7.10 Remove the stage-result hierarchy and terminal-state machine after the real coordinator matrices cover the fixed order. +- [x] 7.11 Remove isolated stage tests that production cannot construct; retain path-fact, state, disposition, fixture, recovery, and cancellation coverage. ## 8. Prove equivalence and reduction diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs index 410bc0c3c..b56b5d61a 100644 --- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs @@ -1729,6 +1729,37 @@ await Assert.ThrowsAnyAsync(() => Assert.Equal(1, approvalService.RequestCount); } + [Fact] + public async Task Authorization_evaluation_propagates_cancellation_after_actor_result() + { + using var cancellation = new CancellationTokenSource(); + var approvalService = new FixedShellApprovalService(request => + { + cancellation.Cancel(); + return new ShellApprovalMatchResult( + new PersistentGrantStoreStatus.Ready(), + Array.AsReadOnly(request.Candidates.Select(candidate => + new ShellGrantCandidateMatch( + candidate.CandidateId, + Match: null, + GrantCoverage: null, + NearMisses: [])).ToArray())); + }); + var executor = CreateApprovalGatedShellExecutor(approvalService); + var call = new FunctionCallContent( + "call-post-actor-cancellation", + ShellTool.ToolName, + ToolInput.Create("Command", "git status")); + + await Assert.ThrowsAnyAsync(() => + executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/post-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 deleted file mode 100644 index 16280f18f..000000000 --- a/src/Netclaw.Actors.Tests/Tools/ShellPolicyEvaluationTests.cs +++ /dev/null @@ -1,1664 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Actors.Protocol; -using Netclaw.Actors.Tools; -using Netclaw.Configuration; -using Netclaw.Security; -using Netclaw.Tests.Utilities; -using Netclaw.Tools; -using ShellSyntaxTree; -using Xunit; - -namespace Netclaw.Actors.Tests.Tools; - -public sealed class ShellPolicyEvaluationTests -{ - public static bool IsPosix => !OperatingSystem.IsWindows(); - - [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; - TestStage[] stages = - [ - SyntaxStage(ShellTool.ToolName), - (_, _) => - { - laterStageVisited = true; - return ValueTask.FromResult(ShellPolicyStageOutcome.Continue); - } - ]; - - var result = await RunStagesAsync(evaluation, stages, TestContext.Current.CancellationToken); - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - var decision = AssertTerminalDecision(evaluation); - - Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); - Assert.False(laterStageVisited); - Assert.Single(Assert.IsType(decision.ApprovalContext).Candidates!); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task Syntax_stage_honors_exact_one_time_authority_before_prompting(bool isMessy) - { - var candidate = new ApprovalCandidate("git status", "/work"); - var evaluation = CreateEvaluationWithExactOneTime(isMessy, candidate); - - var result = await RunStagesAsync( - evaluation, - [SyntaxStage(ShellTool.ToolName)], - TestContext.Current.CancellationToken); - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - var decision = AssertTerminalDecision(evaluation); - - Assert.Equal(ToolAuthorizationOutcome.Allowed, decision.Outcome); - Assert.Equal(ToolAllowReason.OneTimeApproval, decision.AllowReason); - var trace = Assert.IsType(evaluation.CompletedTrace); - var completion = Assert.Single(trace.Rows); - Assert.Equal(ShellPolicyTraceStage.Completion, completion.Stage); - Assert.Equal(ShellPolicyTraceOutcome.Allow, completion.Outcome); - } - - [Fact] - public async Task Syntax_stage_faults_before_a_later_stage_for_invalid_tokens() - { - var candidate = BashCandidate("git status") with - { - VerbTokens = Array.AsReadOnly(["git status"]) - }; - var evaluation = CreateEvaluation(candidate); - var laterStageVisited = false; - TestStage[] stages = - [ - SyntaxStage(ShellTool.ToolName), - (_, _) => - { - laterStageVisited = true; - return ValueTask.FromResult(ShellPolicyStageOutcome.Continue); - } - ]; - - var result = await RunStagesAsync(evaluation, stages, TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - Assert.Equal(ShellPolicyFault.InvalidProjection, evaluation.TerminalFault); - Assert.False(laterStageVisited); - Assert.Equal("internal_policy_failure", evaluation.TerminalDecision?.DenyReason); - } - - [Fact] - public async Task Protected_causal_path_stage_denies_before_a_later_stage() - { - var (evaluation, policy, _) = CreateCausalEvaluation( - "cd /tmp && inspect; head private.log", - ["/tmp/private.log"]); - var laterStageVisited = false; - TestStage[] stages = - [ - ProtectedCausalPathsStage(policy), - (_, _) => - { - laterStageVisited = true; - return ValueTask.FromResult(ShellPolicyStageOutcome.Continue); - } - ]; - - var result = await RunStagesAsync(evaluation, stages, TestContext.Current.CancellationToken); - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - var decision = AssertTerminalDecision(evaluation); - - Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); - Assert.Equal("shell_references_protected_path", decision.DenyReason); - Assert.False(laterStageVisited); - } - - [Fact] - public async Task Protected_causal_path_stage_checks_each_fallback_base() - { - var (evaluation, policy, _) = CreateCausalEvaluation( - "cd /tmp && inspect; head result.log", - ["/work/result.log"]); - - var result = await RunStagesAsync( - evaluation, - [ProtectedCausalPathsStage(policy)], - TestContext.Current.CancellationToken); - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - var decision = AssertTerminalDecision(evaluation); - - Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); - Assert.Equal("shell_references_protected_path", decision.DenyReason); - } - - [Fact] - public void Causal_protected_path_check_denies_an_invalid_known_value() - { - var (evaluation, policy, _) = CreateCausalEvaluation( - "cd /tmp && inspect; head result.log"); - var consumer = Assert.Single( - evaluation.Candidates, - static candidate => candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer); - var facts = evaluation.Projection.PathFacts[consumer.Id.Value]; - var source = Assert.Single( - Assert.IsType(facts.Intent).Facts, - static fact => fact.Source.Origin == ShellPolicyPathOrigin.EffectiveArgument).Source; - var invalid = new ShellPolicyResolvedPathFact( - source, - ShellPolicyPathResolutionState.InvalidKnownValue, - []); - var invalidFacts = facts with - { - Intent = new ShellPolicyResolvedPathView( - Assert.IsType(facts.Intent).ResolutionBase, - [invalid]) - }; - - Assert.True(policy.CausalIntentReferencesProtectedPath(invalidFacts)); - } - - [Fact] - public void Causal_protected_path_check_does_not_treat_unknown_as_a_denied_path() - { - var (evaluation, policy, _) = CreateCausalEvaluation( - "cd /tmp && inspect; head result.log"); - var consumer = Assert.Single( - evaluation.Candidates, - static candidate => candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer); - var facts = evaluation.Projection.PathFacts[consumer.Id.Value]; - var environment = ShellExecutionEnvironment.CreatePowerShell( - @"C:\Program Files\PowerShell\7\pwsh.exe", - PwshDialect.PowerShell7); - var occurrence = Assert.Single( - new ShellCommandPolicy(environment) - .Analyze("Get-Date > $name", @"C:\work") - .Commands); - var resolutionBase = new ShellPolicyScopePathFact( - @"C:\work", - ShellPolicyPathResolutionState.Known, - CreateCanonicalPath(@"C:\work", ShellPathStyle.Windows)); - var source = Assert.Single( - ShellPolicyOccurrencePathFacts.Create(occurrence) - .Resolve( - resolutionBase, - ShellPathStyle.Windows, - ApprovalShell.PowerShell) - .Facts, - static fact => fact.Source.Origin == ShellPolicyPathOrigin.Redirect).Source; - var unknown = new ShellPolicyResolvedPathFact( - source, - ShellPolicyPathResolutionState.UnknownDynamic, - []); - var unknownFacts = facts with - { - Intent = new ShellPolicyResolvedPathView( - Assert.IsType(facts.Intent).ResolutionBase, - [unknown]), - Fallbacks = facts.Fallbacks - .Select(static view => view with { Facts = [] }) - .ToArray() - }; - - Assert.False(policy.CausalIntentReferencesProtectedPath(unknownFacts)); - } - - [Fact] - public async Task Causal_directory_stage_continues_for_eligible_directories() - { - var (evaluation, policy, _) = CreateCausalEvaluation( - "cd /tmp && inspect; head result.log"); - - var result = await RunStagesAsync( - evaluation, - [CausalDirectoriesStage(policy, ShellTool.ToolName)], - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Continue, result); - Assert.Null(evaluation.TerminalDecision); - } - - [SlopwatchSuppress("SW001", "This test requires native POSIX symbolic-link behavior.")] - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only symbolic-link semantics")] - public async Task Causal_directory_stage_prompts_for_a_symbolic_link_directory() - { - var root = Directory.CreateTempSubdirectory("netclaw-policy-stage-"); - try - { - var target = Path.Combine(root.FullName, "target"); - var alias = Path.Combine(root.FullName, "alias"); - Directory.CreateDirectory(target); - Directory.CreateSymbolicLink(alias, target); - var (evaluation, policy, _) = CreateCausalEvaluation( - $"cd {alias} && inspect; head result.log"); - - var result = await RunStagesAsync( - evaluation, - [CausalDirectoriesStage(policy, ShellTool.ToolName)], - TestContext.Current.CancellationToken); - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - var decision = AssertTerminalDecision(evaluation); - - Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); - Assert.True(Assert.IsType(decision.ApprovalContext).IsMessy); - } - finally - { - root.Delete(recursive: true); - } - } - - [Fact] - public async Task Actor_evidence_stage_applies_one_validated_batch() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var candidate = Assert.Single(evaluation.Candidates); - var service = new FixedShellApprovalService(request => - { - var requested = Assert.Single(request.Candidates); - Assert.Equal(candidate.Id, requested.CandidateId); - return new ShellApprovalMatchResult( - new PersistentGrantStoreStatus.Ready(), - [ - new ShellGrantCandidateMatch( - candidate.Id, - new ToolApprovalMatch(candidate.Candidate.Verb, "session", "this chat"), - ShellCoverageKind.Session, - NearMisses: []) - ]); - }); - - var result = await RunStagesAsync( - evaluation, - [ActorEvidenceStage( - new ShellApprovalEvidenceAdapter(service), - (ToolApprovalSessionId)"signalr/shell-policy-actor-stage", - TrustAudience.Personal, - new ToolName(ShellTool.ToolName))], - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Continue, result); - Assert.Equal(1, service.RequestCount); - Assert.Equal(ShellPolicyCoverageSource.Session, evaluation.CoverageFor(candidate.Id)); - Assert.NotNull(evaluation.GrantEvidence); - Assert.Single(evaluation.ApprovalMatches); - Assert.Equal(ShellPolicyStageOutcome.Complete, evaluation.Complete( - ToolAuthorizationDecision.Allow( - ToolAllowReason.StoredApproval, - evaluation.ApprovalMatches))); - Assert.Collection( - Assert.IsType(evaluation.CompletedTrace).Rows, - row => Assert.Equal(ShellPolicyTraceStage.StoredGrantMatch, row.Stage), - row => Assert.Equal(ShellPolicyTraceStage.Completion, row.Stage)); - } - - [Fact] - public async Task Actor_evidence_stage_rejects_a_malformed_batch_before_later_stages() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var candidate = Assert.Single(evaluation.Candidates); - var service = new FixedShellApprovalService(static _ => new ShellApprovalMatchResult( - new PersistentGrantStoreStatus.Ready(), - CandidateMatches: [])); - var laterStageVisited = false; - TestStage[] stages = - [ - ActorEvidenceStage( - new ShellApprovalEvidenceAdapter(service), - (ToolApprovalSessionId)"signalr/shell-policy-invalid-actor-stage", - TrustAudience.Personal, - new ToolName(ShellTool.ToolName)), - (_, _) => - { - laterStageVisited = true; - return ValueTask.FromResult(ShellPolicyStageOutcome.Continue); - } - ]; - - var result = await RunStagesAsync( - evaluation, - stages, - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - Assert.Equal(ShellPolicyFault.InvalidActorEvidence, evaluation.TerminalFault); - Assert.Equal(1, service.RequestCount); - Assert.False(laterStageVisited); - Assert.Null(evaluation.GrantEvidence); - Assert.Equal(ShellPolicyCoverageSource.Uncovered, evaluation.CoverageFor(candidate.Id)); - Assert.Equal("internal_policy_failure", evaluation.TerminalDecision?.DenyReason); - } - - [Fact] - public void Validated_actor_evidence_remains_bound_to_its_projection() - { - var source = CreateEvaluation(BashCandidate("git status")); - var target = CreateEvaluation(BashCandidate("git status")); - var candidate = Assert.Single(source.Projection.GrantCandidates); - var actorResult = new ShellApprovalMatchResult( - new PersistentGrantStoreStatus.Ready(), - [ - new ShellGrantCandidateMatch( - candidate.Id, - new ToolApprovalMatch(candidate.Candidate.Verb, "session", "this chat"), - ShellCoverageKind.Session, - NearMisses: []) - ]); - Assert.True(ValidatedShellGrantEvidence.TryCreate( - actorResult, - source.Projection.GrantCandidates, - source.Projection.ApprovalContext.Cwd, - out var evidence)); - - var result = target.ApplyActorEvidence(Assert.IsType(evidence)); - - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - Assert.Equal(ShellPolicyFault.InvalidActorEvidence, target.TerminalFault); - Assert.Equal("internal_policy_failure", target.TerminalDecision?.DenyReason); - Assert.Equal( - ShellPolicyCoverageSource.Uncovered, - target.CoverageFor(Assert.Single(target.Candidates).Id)); - } - - [Fact] - public async Task Actor_evidence_precedes_approval_exempt_trace_rows() - { - var evaluation = CreateEvaluation( - BashCandidate("git status"), - BashCandidate("echo") with { Directory = null }); - var grantCandidate = evaluation.Candidates[0]; - var sideEffect = evaluation.Candidates[1]; - Assert.True(ApprovalPatternMatching.IsPureSideEffect(sideEffect.Candidate)); - var service = new FixedShellApprovalService(request => - { - Assert.Single(request.Candidates); - return new ShellApprovalMatchResult( - new PersistentGrantStoreStatus.Ready(), - [ - new ShellGrantCandidateMatch( - grantCandidate.Id, - Match: null, - GrantCoverage: null, - NearMisses: []) - ]); - }); - var adapter = new ShellApprovalEvidenceAdapter(service); - - var result = await RunStagesAsync( - evaluation, - [ - ActorEvidenceStage( - adapter, - (ToolApprovalSessionId)"signalr/shell-policy-trace-order", - TrustAudience.Personal, - new ToolName(ShellTool.ToolName)), - ApprovalExemptSideEffectsStage(adapter.IsAvailable) - ], - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Continue, result); - Assert.Equal(ShellPolicyCoverageSource.Uncovered, evaluation.CoverageFor(grantCandidate.Id)); - Assert.Equal( - ShellPolicyCoverageSource.ApprovalExemptSideEffect, - evaluation.CoverageFor(sideEffect.Id)); - Assert.Equal(ShellPolicyStageOutcome.Complete, evaluation.Complete( - ToolAuthorizationDecision.RequiresApproval(evaluation.Projection.ApprovalContext))); - Assert.Collection( - Assert.IsType(evaluation.CompletedTrace).Rows, - row => Assert.Equal(ShellPolicyTraceStage.StoredGrantMatch, row.Stage), - row => - { - Assert.Equal(ShellPolicyTraceStage.ReviewedSafePolicy, row.Stage); - Assert.Equal(ShellPolicyTraceReason.ApprovalExemptSideEffect, row.Reason); - }, - row => Assert.Equal(ShellPolicyTraceStage.Completion, row.Stage)); - } - - [Theory] - [InlineData(false, nameof(ShellPolicyCoverageSource.Uncovered))] - [InlineData(true, nameof(ShellPolicyCoverageSource.ApprovalExemptSideEffect))] - public async Task Approval_exempt_stage_follows_approval_service_availability( - bool serviceAvailable, - string expectedCoverageName) - { - var evaluation = CreateEvaluation(BashCandidate("echo") with { Directory = null }); - var candidate = Assert.Single(evaluation.Candidates); - var service = new FixedShellApprovalService(static _ => new ShellApprovalMatchResult( - new PersistentGrantStoreStatus.Ready(), - CandidateMatches: [])); - var adapter = new ShellApprovalEvidenceAdapter(serviceAvailable ? service : null); - - var result = await RunStagesAsync( - evaluation, - [ - ActorEvidenceStage( - adapter, - sessionId: null, - TrustAudience.Personal, - new ToolName(ShellTool.ToolName)), - ApprovalExemptSideEffectsStage(adapter.IsAvailable) - ], - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Continue, result); - Assert.NotNull(evaluation.GrantEvidence); - Assert.Equal(0, service.RequestCount); - Assert.Equal( - Enum.Parse(expectedCoverageName), - evaluation.CoverageFor(candidate.Id)); - } - - [Theory] - [InlineData(false, nameof(ShellPolicyCoverageSource.Uncovered))] - [InlineData(true, nameof(ShellPolicyCoverageSource.ReviewedSafeReal))] - public async Task Reviewed_safe_real_scope_stage_requires_interactive_approval( - bool interactive, - string expectedCoverageName) - { - var (evaluation, policy, context) = CreateReviewedSafeEvaluation( - "head README.md", - interactive, - "head"); - var candidate = Assert.Single(evaluation.Candidates); - - var result = await RunStagesAsync( - evaluation, - [RealScopeStage(policy, context.Invocation)], - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Continue, result); - Assert.Equal( - Enum.Parse(expectedCoverageName), - evaluation.CoverageFor(candidate.Id)); - } - - [Theory] - [InlineData("grep -f ./patterns ./data.txt", true)] - [InlineData("du -sh ./*", true)] - [InlineData("tr -d '\\n'", true)] - [InlineData("tool -d '\\n'", false)] - [InlineData("tr *.txt x", false)] - [InlineData("tr -d '\\n' > /external/out", false)] - [InlineData("grep -f /external/patterns ./data.txt", false)] - [InlineData("head 'C:\\temp\\file.log'", false)] - public async Task Reviewed_safe_real_scope_stage_uses_projected_path_facts( - string command, - bool allCovered) - { - var phrase = command.Split(' ', 2)[0]; - var (evaluation, policy, context) = CreateReviewedSafeEvaluation( - command, - interactive: true, - phrase); - - var result = await RunStagesAsync( - evaluation, - [RealScopeStage(policy, context.Invocation)], - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Continue, result); - var actual = evaluation.Candidates.All(candidate => evaluation.IsCovered(candidate.Id)); - Assert.True( - actual == allCovered, - string.Join( - "; ", - evaluation.Candidates.Select(candidate => - { - var facts = evaluation.Projection.PathFacts[candidate.Id.Value]; - return $"{candidate.Candidate.Verb}: " - + $"directory={candidate.Candidate.Directory}; " - + $"sourceCwd={candidate.SourceOccurrence?.WorkingDirectory}; " - + $"real={facts.RealScope}; " - + $"facts=[{string.Join(", ", facts.Real.Facts)}]"; - }))); - } - - [Fact] - public void Unproved_non_file_semantics_keep_reviewed_safe_policy_strict() - { - var (evaluation, policy, context) = CreateReviewedSafeEvaluation( - "tr -d '\\n'", - interactive: true, - "tr"); - var candidate = Assert.Single(evaluation.Candidates); - var facts = evaluation.Projection.PathFacts[candidate.Id.Value]; - var invalid = facts with - { - Real = facts.Real with { HasUnprovedNonFileSystemSemantics = true } - }; - - Assert.False(policy.IsReviewedSafeCandidate( - candidate, - invalid, - context.Invocation)); - } - - [Fact] - public async Task Reviewed_safe_real_scope_stage_keeps_declared_windows_roots() - { - var environment = ShellExecutionEnvironment.CreatePowerShell( - @"C:\Program Files\PowerShell\7\pwsh.exe", - PwshDialect.PowerShell7); - var (evaluation, policy, context) = CreateReviewedSafeEvaluation( - @"Get-Content -LiteralPath C:\work\data.txt", - true, - environment, - ApprovalShell.PowerShell, - @"C:\work", - "Get-Content"); - - var result = await RunStagesAsync( - evaluation, - [RealScopeStage(policy, context.Invocation)], - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Continue, result); - var candidate = Assert.Single(evaluation.Candidates); - Assert.True(evaluation.IsCovered(candidate.Id)); - } - - [SlopwatchSuppress("SW001", "This test pins Bash causal approval intent on POSIX hosts.")] - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")] - public async Task Reviewed_safe_intent_stage_requires_real_prerequisite_coverage() - { - var (evaluation, policy, context) = CreateCausalEvaluation( - "cd /tmp && inspect; head result.log", - safeVerbs: SafeVerbList.FromVerbs(ApprovalShell.Bash, ["head"])); - var consumer = Assert.Single( - evaluation.Candidates, - candidate => candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer); - Assert.NotEmpty(consumer.IntentPrerequisites); - - var beforeCoverage = await RunStagesAsync( - evaluation, - [IntentScopeStage(policy, context.Invocation)], - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Continue, beforeCoverage); - Assert.Equal(ShellPolicyCoverageSource.Uncovered, evaluation.CoverageFor(consumer.Id)); - - foreach (var prerequisiteId in consumer.IntentPrerequisites) - { - var prerequisite = evaluation.Candidates[prerequisiteId.Value]; - Assert.Equal(ShellPolicyStageOutcome.Continue, evaluation.Cover( - prerequisite, - ShellPolicyCoverageSource.Session)); - } - - var afterCoverage = await RunStagesAsync( - evaluation, - [ - RealScopeStage(policy, context.Invocation), - IntentScopeStage(policy, context.Invocation) - ], - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Continue, afterCoverage); - Assert.Equal( - ShellPolicyCoverageSource.ReviewedSafeIntent, - evaluation.CoverageFor(consumer.Id)); - Assert.Equal(ShellPolicyStageOutcome.Complete, evaluation.Complete( - ToolAuthorizationDecision.Allow(ToolAllowReason.StoredApproval))); - Assert.Contains( - Assert.IsType(evaluation.CompletedTrace).Rows, - row => row is - { - Stage: ShellPolicyTraceStage.ReviewedSafePolicy, - Reason: ShellPolicyTraceReason.ReviewedSafePhrase, - ScopeRelation: ShellScopeRelation.UnderIntentRoot - }); - } - - [Fact] - public async Task Exact_one_time_stage_covers_the_remaining_candidate_set() - { - var evaluation = CreateEvaluationWithExactOneTime( - isMessy: false, - BashCandidate("git status")); - var candidate = Assert.Single(evaluation.Candidates); - - var result = await RunStagesAsync( - evaluation, - [ExactOneTimeStage( - new ToolName(ShellTool.ToolName), - "/work/session")], - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Continue, result); - Assert.True(evaluation.HasOneTimeCoverage); - Assert.Equal(ShellPolicyCoverageSource.OneTime, evaluation.CoverageFor(candidate.Id)); - Assert.Equal(ShellPolicyStageOutcome.Complete, evaluation.Complete( - ToolAuthorizationDecision.Allow(ToolAllowReason.OneTimeApproval))); - Assert.Collection( - Assert.IsType(evaluation.CompletedTrace).Rows, - row => - { - Assert.Equal(ShellPolicyTraceStage.OneTimeApproval, row.Stage); - Assert.Equal(ShellPolicyTraceReason.OneTimeGrant, row.Reason); - }, - row => Assert.Equal(ShellPolicyTraceStage.Completion, row.Stage)); - } - - [Fact] - public async Task Exact_one_time_and_prompt_share_one_uncovered_context() - { - var evaluation = CreateEvaluationWithExactOneTime( - isMessy: false, - BashCandidate("git status")); - var candidate = Assert.Single(evaluation.Candidates); - var context = TestToolExecutionContext.CreateBound( - "signalr/shell-policy-shared-context", - "/work/session", - TrustAudience.Personal); - - var result = await RunStagesAsync( - evaluation, - [ExactOneTimeStage( - new ToolName("different_tool"), - context.SessionDirectory)], - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Continue, result); - Assert.False(evaluation.HasOneTimeCoverage); - Assert.Equal(ShellPolicyCoverageSource.Uncovered, evaluation.CoverageFor(candidate.Id)); - - var oneTimeContext = evaluation.GetUncoveredApprovalContext(context.SessionDirectory); - var terminal = await RunStagesAsync( - evaluation, - [CompleteStage(context)], - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Complete, terminal); - Assert.Same(oneTimeContext, AssertTerminalDecision(evaluation).ApprovalContext); - } - - [Fact] - public void Uncovered_context_reprojects_after_candidate_coverage_changes() - { - var evaluation = CreateEvaluation( - BashCandidate("git status"), - BashCandidate("git push")); - var initial = evaluation.GetUncoveredApprovalContext("/work/session"); - var candidate = evaluation.Candidates[0]; - Assert.Equal(ShellPolicyStageOutcome.Continue, evaluation.Cover( - candidate, - ShellPolicyCoverageSource.Session)); - - var reprojected = evaluation.GetUncoveredApprovalContext("/work/session"); - - Assert.NotSame(initial, reprojected); - Assert.Equal([evaluation.Candidates[1].Candidate], reprojected.Candidates); - } - - [Fact] - public void Uncovered_context_reprojects_when_session_directory_changes() - { - var candidate = BashCandidate("git status") with { Directory = "/work/repo" }; - var evaluation = CreateEvaluation( - isMessy: false, - hasExactOneTimeApproval: false, - cwd: "/work/repo", - candidates: [candidate]); - var sessionScratch = evaluation.GetUncoveredApprovalContext("/work/repo"); - - var ordinaryScope = evaluation.GetUncoveredApprovalContext("/work/session"); - - Assert.NotSame(sessionScratch, ordinaryScope); - Assert.DoesNotContain( - sessionScratch.Options, - static option => option.Key == ApprovalOptionKeys.ApproveAlwaysKey); - Assert.Contains( - ordinaryScope.Options, - static option => option.Key == ApprovalOptionKeys.ApproveAlwaysKey); - } - - [SlopwatchSuppress("SW001", "This test pins Bash causal approval intent on POSIX hosts.")] - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")] - public void Causal_uncovered_context_retains_the_complete_approval_context() - { - var (evaluation, _, context) = CreateCausalEvaluation( - "cd /tmp && inspect; head result.log", - safeVerbs: SafeVerbList.FromVerbs(ApprovalShell.Bash, ["head"])); - - var uncovered = evaluation.GetUncoveredApprovalContext(context.SessionDirectory); - - Assert.Same(evaluation.Projection.ApprovalContext, uncovered); - } - - [Fact] - public void Path_facts_preserve_real_scope_and_source_resolution() - { - var (evaluation, _, _) = CreateReviewedSafeEvaluation( - "head README.md", - interactive: true, - "head"); - var candidate = Assert.Single(evaluation.Candidates); - - var facts = evaluation.Projection.PathFacts[candidate.Id.Value]; - - Assert.Equal(ShellPolicyPathResolutionState.Known, facts.RealScope.State); - Assert.Equal("/work", facts.RealScope.Path?.Value); - Assert.Contains( - facts.Real.Facts, - fact => fact.Source.Origin == ShellPolicyPathOrigin.EffectiveArgument - && fact.Source.Domain is ShellValueDomain.Exact - && fact.State == ShellPolicyPathResolutionState.Known - && fact.Paths.Any(path => path.Value == "/work/README.md")); - } - - [Theory] - [InlineData(false, "head /external/file.log", "/work", "/external/file.log")] - [InlineData(true, @"Get-Content C:\external\file.log", @"C:\work", @"C:\external\file.log")] - public void Path_facts_do_not_rebase_absolute_paths_beneath_the_resolution_base( - bool windowsStyle, - string command, - string resolutionBase, - string expected) - { - var environment = windowsStyle - ? ShellExecutionEnvironment.CreatePowerShell( - @"C:\Program Files\PowerShell\7\pwsh.exe", - PwshDialect.PowerShell7) - : ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); - var occurrence = Assert.Single( - new ShellCommandPolicy(environment).Analyze(command, resolutionBase).Commands); - var scope = new ShellPolicyScopePathFact( - resolutionBase, - ShellPolicyPathResolutionState.Known, - CreateCanonicalPath(resolutionBase, environment.PathStyle)); - - var facts = ShellPolicyOccurrencePathFacts.Create(occurrence) - .Resolve( - scope, - environment.PathStyle, - windowsStyle ? ApprovalShell.PowerShell : ApprovalShell.Bash); - - Assert.Contains( - facts.Facts, - fact => fact.Source.Origin == ShellPolicyPathOrigin.EffectiveArgument - && fact.State == ShellPolicyPathResolutionState.Known - && fact.Paths.Any(path => path.Value == expected)); - } - - [Theory] - [InlineData(@"\external\file.log")] - [InlineData(@"D:file.log")] - [InlineData(@"FileSystem::C:\external\file.log")] - public void Path_facts_keep_ambiguous_windows_root_forms_strict(string value) - { - Assert.False(ShellPolicyOccurrencePathFacts.TryResolveCanonicalPath( - value, - @"C:\work", - ShellPathStyle.Windows, - out _)); - } - - [Fact] - public void Path_facts_keep_candidate_scope_separate_from_the_command_base() - { - var (evaluation, _, _) = CreateReviewedSafeEvaluation( - "cat /work/sub/file.txt", - interactive: true, - "cat"); - var candidate = Assert.Single( - evaluation.Candidates, - static candidate => candidate.Candidate.Directory == "/work/sub"); - - var facts = evaluation.Projection.PathFacts[candidate.Id.Value]; - - Assert.Equal("/work/sub", facts.RealScope.Path?.Value); - Assert.Equal("/work", facts.Real.ResolutionBase.Path?.Value); - Assert.Contains( - facts.Real.Facts, - fact => fact.Source.Origin == ShellPolicyPathOrigin.EffectiveArgument - && fact.Paths.Any(path => path.Value == "/work/sub/file.txt")); - } - - [SlopwatchSuppress("SW001", "This test pins Bash causal path facts on POSIX hosts.")] - [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")] - public void Causal_path_facts_keep_intent_and_fallback_resolutions_distinct() - { - var (evaluation, _, _) = CreateCausalEvaluation( - "cd /tmp && inspect; head result.log"); - var candidate = Assert.Single( - evaluation.Candidates, - static candidate => candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer); - - var facts = evaluation.Projection.PathFacts[candidate.Id.Value]; - - Assert.Equal("/tmp", facts.Intent?.ResolutionBase.Path?.Value); - Assert.Contains(facts.Fallbacks, view => view.ResolutionBase.Path?.Value == "/work"); - Assert.Contains( - Assert.IsType(facts.Intent).Facts, - fact => fact.Source.Origin == ShellPolicyPathOrigin.EffectiveArgument - && fact.Paths.Any(path => path.Value == "/tmp/result.log")); - Assert.Contains( - facts.Fallbacks.SelectMany(static view => view.Facts), - fact => fact.Source.Origin == ShellPolicyPathOrigin.EffectiveArgument - && fact.Paths.Any(path => path.Value == "/work/result.log")); - } - - [Fact] - public void Path_facts_distinguish_unknown_values_from_invalid_known_values() - { - var environment = ShellExecutionEnvironment.CreatePowerShell( - @"C:\Program Files\PowerShell\7\pwsh.exe", - PwshDialect.PowerShell7); - var policy = new ShellCommandPolicy(environment); - var occurrence = Assert.Single(policy.Analyze("Get-Date > $name", @"C:\work").Commands); - var source = ShellPolicyOccurrencePathFacts.Create(occurrence); - var realScope = new ShellPolicyScopePathFact( - "C:/work", - ShellPolicyPathResolutionState.Known, - CreateCanonicalPath(@"C:\work", ShellPathStyle.Windows)); - - var resolved = source.Resolve( - realScope, - ShellPathStyle.Windows, - ApprovalShell.PowerShell); - - Assert.Contains( - resolved.Facts, - fact => fact.Source.Origin == ShellPolicyPathOrigin.Redirect - && fact.Source.Domain is ShellValueDomain.Unknown - && fact.State == ShellPolicyPathResolutionState.UnknownDynamic); - Assert.DoesNotContain( - resolved.Facts, - static fact => fact.State == ShellPolicyPathResolutionState.InvalidKnownValue); - } - - [Fact] - public void Path_facts_retain_redirect_mode_and_domain() - { - var environment = ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); - var policy = new ShellCommandPolicy(environment); - var occurrence = Assert.Single(policy.Analyze("cat input.txt > output.txt", "/work").Commands); - var source = ShellPolicyOccurrencePathFacts.Create(occurrence); - var realScope = new ShellPolicyScopePathFact( - "/work", - ShellPolicyPathResolutionState.Known, - CreateCanonicalPath("/work", ShellPathStyle.Posix)); - - var resolved = source.Resolve( - realScope, - ShellPathStyle.Posix, - ApprovalShell.Bash); - var redirect = Assert.Single( - resolved.Facts, - static fact => fact.Source.Origin == ShellPolicyPathOrigin.Redirect); - - Assert.Equal(FileRedirectMode.Output, redirect.Source.RedirectMode); - Assert.True(redirect.Source.RedirectIsComplete); - Assert.IsType(redirect.Source.Domain); - Assert.Equal(ShellPolicyPathResolutionState.Known, redirect.State); - Assert.Equal("/work/output.txt", Assert.Single(redirect.Paths).Value); - } - - [Theory] - [InlineData(false, true)] - [InlineData(true, false)] - public async Task Persistent_store_stage_denies_only_uncovered_candidates( - bool coveredBySession, - bool expectsDeny) - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var candidate = Assert.Single(evaluation.Candidates); - var service = new FixedShellApprovalService(_ => new ShellApprovalMatchResult( - new PersistentGrantStoreStatus.Unavailable(ApprovalStoreFailure.IoFailure), - [ - new ShellGrantCandidateMatch( - candidate.Id, - coveredBySession - ? new ToolApprovalMatch(candidate.Candidate.Verb, "session", "this chat") - : null, - coveredBySession ? ShellCoverageKind.Session : null, - NearMisses: []) - ])); - - var result = await RunStagesAsync( - evaluation, - [ - ActorEvidenceStage( - new ShellApprovalEvidenceAdapter(service), - (ToolApprovalSessionId)"signalr/shell-policy-store-stage", - TrustAudience.Personal, - new ToolName(ShellTool.ToolName)), - PersistentStoreAvailabilityStage() - ], - TestContext.Current.CancellationToken); - - Assert.Equal(1, service.RequestCount); - if (!expectsDeny) - { - Assert.Equal(ShellPolicyStageOutcome.Continue, result); - Assert.Null(evaluation.TerminalDecision); - Assert.Equal(ShellPolicyCoverageSource.Session, evaluation.CoverageFor(candidate.Id)); - } - else - { - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - var decision = AssertTerminalDecision(evaluation); - Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); - Assert.Equal("approval_store_unavailable", decision.DenyReason); - Assert.Equal(ShellPolicyCoverageSource.Uncovered, evaluation.CoverageFor(candidate.Id)); - } - } - - [Fact] - public async Task Persistent_store_stage_rejects_missing_actor_evidence() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - - var result = await RunStagesAsync( - evaluation, - [PersistentStoreAvailabilityStage()], - TestContext.Current.CancellationToken); - - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - Assert.Equal(ShellPolicyFault.InvalidActorEvidence, evaluation.TerminalFault); - Assert.Equal("internal_policy_failure", evaluation.TerminalDecision?.DenyReason); - } - - [Fact] - public async Task Terminal_stage_prompts_with_only_the_uncovered_candidates() - { - var evaluation = CreateEvaluation( - BashCandidate("git status"), - BashCandidate("git push")); - var covered = evaluation.Candidates[0]; - var uncovered = evaluation.Candidates[1]; - Assert.Equal(ShellPolicyStageOutcome.Continue, evaluation.Cover( - covered, - ShellPolicyCoverageSource.Session)); - var context = TestToolExecutionContext.CreateBound( - "signalr/shell-policy-terminal-prompt", - "/work/session", - TrustAudience.Personal); - - var result = await RunStagesAsync( - evaluation, - [CompleteStage(context)], - TestContext.Current.CancellationToken); - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - var decision = AssertTerminalDecision(evaluation); - - Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); - var prompt = Assert.IsType(decision.ApprovalContext); - Assert.Equal([uncovered.Candidate], prompt.Candidates); - Assert.Equal(ShellPolicyCoverageSource.Session, evaluation.CoverageFor(covered.Id)); - Assert.Equal(ShellPolicyCoverageSource.Uncovered, evaluation.CoverageFor(uncovered.Id)); - } - - [Fact] - public async Task Terminal_stage_preserves_one_time_allow_precedence() - { - var evaluation = CreateEvaluationWithExactOneTime( - isMessy: false, - BashCandidate("git status")); - var context = TestToolExecutionContext.CreateBound( - "signalr/shell-policy-terminal-once", - "/work/session", - TrustAudience.Personal); - - var result = await RunStagesAsync( - evaluation, - [ - ExactOneTimeStage( - new ToolName(ShellTool.ToolName), - context.SessionDirectory), - CompleteStage(context) - ], - TestContext.Current.CancellationToken); - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - var decision = AssertTerminalDecision(evaluation); - - Assert.Equal(ToolAuthorizationOutcome.Allowed, decision.Outcome); - Assert.Equal(ToolAllowReason.OneTimeApproval, decision.AllowReason); - } - - [Fact] - public async Task Terminal_stage_records_a_complete_stored_match_decision() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var candidate = Assert.Single(evaluation.Candidates); - var context = TestToolExecutionContext.CreateBound( - "signalr/shell-policy-terminal-stored", - "/work/session", - TrustAudience.Personal); - var service = new FixedShellApprovalService(_ => new ShellApprovalMatchResult( - new PersistentGrantStoreStatus.Ready(), - [ - new ShellGrantCandidateMatch( - candidate.Id, - new ToolApprovalMatch(candidate.Candidate.Verb, "session", "this chat"), - ShellCoverageKind.Session, - NearMisses: []) - ])); - - var result = await RunStagesAsync( - evaluation, - [ - ActorEvidenceStage( - new ShellApprovalEvidenceAdapter(service), - (ToolApprovalSessionId)"signalr/shell-policy-terminal-stored", - TrustAudience.Personal, - new ToolName(ShellTool.ToolName)), - CompleteStage(context) - ], - TestContext.Current.CancellationToken); - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - var decision = AssertTerminalDecision(evaluation); - - Assert.Equal(ToolAuthorizationOutcome.Allowed, decision.Outcome); - Assert.Equal(ToolAllowReason.StoredApproval, decision.AllowReason); - Assert.Equal("PreviouslyApproved", context.Approval.AppliedDecision); - Assert.Equal("git status [session: this chat]", context.Approval.AppliedPattern); - } - - [Fact] - public void Coverage_and_trace_change_together() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var candidate = Assert.Single(evaluation.Candidates); - var grantTimestamp = new DateTimeOffset(2026, 8, 14, 0, 0, 0, TimeSpan.Zero); - - var result = evaluation.Cover( - candidate, - ShellPolicyCoverageSource.PersistentGlobal, - grantTimestamp); - - Assert.Equal(ShellPolicyStageOutcome.Continue, result); - Assert.Equal(ShellPolicyCoverageSource.PersistentGlobal, evaluation.CoverageFor(candidate.Id)); - - var decision = ToolAuthorizationDecision.Allow(ToolAllowReason.StoredApproval); - Assert.Equal(ShellPolicyStageOutcome.Complete, evaluation.Complete(decision)); - Assert.NotNull(evaluation.CompletedTrace); - var rows = evaluation.CompletedTrace.Rows; - Assert.Collection( - rows, - row => - { - Assert.Equal(ShellPolicyTraceStage.StoredGrantMatch, row.Stage); - Assert.Equal(ShellCoverageKind.PersistentGlobal, row.Coverage); - Assert.Equal(ShellPolicyTraceReason.PersistentGlobalGrant, row.Reason); - Assert.Equal(ShellScopeRelation.Global, row.ScopeRelation); - Assert.Equal(grantTimestamp, row.GrantTimestamp); - }, - row => - { - Assert.Equal(ShellPolicyTraceStage.Completion, row.Stage); - Assert.Equal(ShellPolicyTraceOutcome.Allow, row.Outcome); - }); - } - - [Fact] - public void Candidate_view_cannot_replace_projection_identity() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - - Assert.IsNotType(evaluation.Candidates); - var list = Assert.IsAssignableFrom>(evaluation.Candidates); - Assert.Throws(() => - list[0] = list[0] with { Candidate = BashCandidate("git push") }); - } - - [Fact] - public void Duplicate_coverage_fails_without_a_second_trace_row() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var candidate = Assert.Single(evaluation.Candidates); - Assert.Equal(ShellPolicyStageOutcome.Continue, evaluation.Cover( - candidate, - ShellPolicyCoverageSource.Session)); - - var duplicate = evaluation.Cover( - candidate, - ShellPolicyCoverageSource.PersistentGlobal); - - Assert.Equal(ShellPolicyStageOutcome.Complete, duplicate); - Assert.Equal(ShellPolicyCoverageSource.Session, evaluation.CoverageFor(candidate.Id)); - Assert.Equal(ToolAuthorizationOutcome.Denied, evaluation.TerminalDecision?.Outcome); - Assert.Equal(ShellPolicyFault.CoverageAlreadyAssigned, evaluation.TerminalFault); - Assert.NotNull(evaluation.CompletedTrace); - Assert.Equal(2, evaluation.CompletedTrace.Rows.Count); - Assert.Equal(ShellPolicyTraceOutcome.Deny, evaluation.CompletedTrace.Rows[^1].Outcome); - } - - [Fact] - public void Changed_candidate_facts_fail_closed() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var candidate = Assert.Single(evaluation.Candidates); - var changed = candidate with - { - Candidate = BashCandidate("git push") - }; - - var result = evaluation.Cover( - changed, - ShellPolicyCoverageSource.Session); - - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - Assert.Equal(ShellPolicyCoverageSource.Uncovered, evaluation.CoverageFor(candidate.Id)); - Assert.Equal(ToolAuthorizationOutcome.Denied, evaluation.TerminalDecision?.Outcome); - Assert.Equal(ShellPolicyFault.CandidateFactsChanged, evaluation.TerminalFault); - Assert.NotNull(evaluation.CompletedTrace); - Assert.Single(evaluation.CompletedTrace.Rows); - - var later = evaluation.Cover( - candidate, - ShellPolicyCoverageSource.Session); - Assert.Equal(ShellPolicyStageOutcome.Complete, later); - Assert.Equal(ShellPolicyCoverageSource.Uncovered, evaluation.CoverageFor(candidate.Id)); - } - - [Fact] - public void Invalid_candidate_id_fails_closed() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var changed = Assert.Single(evaluation.Candidates) with - { - Id = new ShellPolicyCandidateId(7) - }; - - var result = evaluation.Cover( - changed, - ShellPolicyCoverageSource.Session); - - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - Assert.Equal(ShellPolicyFault.InvalidCandidateId, evaluation.TerminalFault); - Assert.Single(evaluation.UncoveredCandidates); - Assert.Equal(ToolAuthorizationOutcome.Denied, evaluation.TerminalDecision?.Outcome); - } - - [Fact] - public void Invalid_coverage_enum_fails_closed() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var candidate = Assert.Single(evaluation.Candidates); - - var result = evaluation.Cover( - candidate, - (ShellPolicyCoverageSource)999); - - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - Assert.Equal(ShellPolicyFault.InvalidCoverage, evaluation.TerminalFault); - Assert.Equal(ShellPolicyCoverageSource.Uncovered, evaluation.CoverageFor(candidate.Id)); - Assert.Equal(ToolAuthorizationOutcome.Denied, evaluation.TerminalDecision?.Outcome); - Assert.NotNull(evaluation.CompletedTrace); - Assert.Single(evaluation.CompletedTrace.Rows); - } - - [Fact] - public void Session_coverage_rejects_a_persistent_timestamp() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var candidate = Assert.Single(evaluation.Candidates); - - var result = evaluation.Cover( - candidate, - ShellPolicyCoverageSource.Session, - new DateTimeOffset(2026, 8, 14, 0, 0, 0, TimeSpan.Zero)); - - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - Assert.Equal(ShellPolicyFault.InvalidCoverage, evaluation.TerminalFault); - Assert.Equal(ShellPolicyCoverageSource.Uncovered, evaluation.CoverageFor(candidate.Id)); - Assert.Equal(ToolAuthorizationOutcome.Denied, evaluation.TerminalDecision?.Outcome); - Assert.NotNull(evaluation.CompletedTrace); - Assert.Single(evaluation.CompletedTrace.Rows); - Assert.Equal(ShellPolicyTraceStage.Completion, evaluation.CompletedTrace.Rows[0].Stage); - } - - [Fact] - public void Allow_requires_every_candidate_to_have_coverage() - { - var evaluation = CreateEvaluation( - BashCandidate("git status"), - BashCandidate("git diff")); - Assert.Equal(ShellPolicyStageOutcome.Continue, evaluation.Cover( - evaluation.Candidates[0], - ShellPolicyCoverageSource.Session)); - - var result = evaluation.Complete( - ToolAuthorizationDecision.Allow(ToolAllowReason.StoredApproval)); - - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - Assert.Equal(ToolAuthorizationOutcome.Denied, evaluation.TerminalDecision?.Outcome); - Assert.Equal(ShellPolicyFault.InvalidTerminalTransition, evaluation.TerminalFault); - Assert.NotNull(evaluation.CompletedTrace); - Assert.Equal(2, evaluation.CompletedTrace.Rows.Count); - Assert.Equal(ShellPolicyTraceStage.StoredGrantMatch, evaluation.CompletedTrace.Rows[0].Stage); - Assert.Equal(ShellPolicyTraceOutcome.Deny, evaluation.CompletedTrace.Rows[1].Outcome); - } - - [Theory] - [InlineData(nameof(ShellPolicyStageOutcome.Invalid))] - [InlineData("Unknown")] - public void Invalid_stage_outcome_fails_closed(string outcomeName) - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var outcome = outcomeName == nameof(ShellPolicyStageOutcome.Invalid) - ? ShellPolicyStageOutcome.Invalid - : (ShellPolicyStageOutcome)999; - - Assert.False(evaluation.ApplyStageOutcome(outcome)); - Assert.Equal(ShellPolicyFault.InvalidStageResult, evaluation.TerminalFault); - Assert.Equal("internal_policy_failure", evaluation.TerminalDecision?.DenyReason); - } - - [Theory] - [InlineData(nameof(ShellPolicyStageOutcome.Continue), true)] - [InlineData(nameof(ShellPolicyStageOutcome.Complete), false)] - public void Stage_outcome_must_match_terminal_state( - string outcomeName, - bool precomplete) - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - if (precomplete) - { - evaluation.Complete( - ToolAuthorizationDecision.RequiresApproval(evaluation.Projection.ApprovalContext)); - } - - var outcome = Enum.Parse(outcomeName); - - Assert.False(evaluation.ApplyStageOutcome(outcome)); - Assert.Equal(ShellPolicyFault.InvalidStageResult, evaluation.TerminalFault); - Assert.Equal("internal_policy_failure", evaluation.TerminalDecision?.DenyReason); - } - - [Fact] - public void Multiple_terminal_results_return_the_first_terminal_result() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var prompt = ToolAuthorizationDecision.RequiresApproval(evaluation.Projection.ApprovalContext); - Assert.Equal(ShellPolicyStageOutcome.Complete, evaluation.Complete(prompt)); - - var result = evaluation.Complete( - ToolAuthorizationDecision.Deny("internal_policy_failure")); - - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - Assert.Same(prompt, evaluation.TerminalDecision); - Assert.NotNull(evaluation.CompletedTrace); - Assert.Single(evaluation.CompletedTrace.Rows); - } - - [Fact] - public void Coverage_cannot_change_after_a_terminal_allow() - { - var evaluation = CreateEvaluation(BashCandidate("git status")); - var candidate = Assert.Single(evaluation.Candidates); - Assert.Equal(ShellPolicyStageOutcome.Continue, evaluation.Cover( - candidate, - ShellPolicyCoverageSource.Session)); - var allow = ToolAuthorizationDecision.Allow(ToolAllowReason.StoredApproval); - Assert.Equal(ShellPolicyStageOutcome.Complete, evaluation.Complete(allow)); - - var result = evaluation.Cover( - candidate, - ShellPolicyCoverageSource.PersistentGlobal); - - Assert.Equal(ShellPolicyStageOutcome.Complete, result); - Assert.Same(allow, evaluation.TerminalDecision); - Assert.Equal(ShellPolicyCoverageSource.Session, evaluation.CoverageFor(candidate.Id)); - Assert.NotNull(evaluation.CompletedTrace); - Assert.Equal(2, evaluation.CompletedTrace.Rows.Count); - Assert.Equal(ShellPolicyTraceOutcome.Allow, evaluation.CompletedTrace.Rows[^1].Outcome); - } - - [Fact] - public void Prompt_can_complete_without_reusable_candidates() - { - var evaluation = CreateEvaluation(); - var prompt = ToolAuthorizationDecision.RequiresApproval(evaluation.Projection.ApprovalContext); - - Assert.Equal(ShellPolicyStageOutcome.Complete, evaluation.Complete(prompt)); - Assert.Same(prompt, evaluation.TerminalDecision); - Assert.NotNull(evaluation.CompletedTrace); - Assert.Equal(ShellPolicyTraceOutcome.RequiresApproval, evaluation.CompletedTrace.Rows[^1].Outcome); - } - - [Fact] - public void Analysis_cannot_attach_to_a_prompt_result() - { - var projection = CreateEvaluation(BashCandidate("git status")).Projection; - var analysis = new ShellCommandPolicy(projection.Environment) - .Analyze("git status", "/work"); - var prompt = ToolAuthorizationDecision.RequiresApproval(projection.ApprovalContext); - - Assert.Throws(() => new ShellPolicyAuthorization(prompt, analysis)); - Assert.Throws(() => new ShellPolicyPreflightResult.Complete( - ToolAccessDecision.RequiresApproval(projection.ApprovalContext), - 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.ApplyStageOutcome(result)) - return result; - } - - return ShellPolicyStageOutcome.Continue; - } - - private static ToolAuthorizationDecision AssertTerminalDecision( - ShellPolicyEvaluation evaluation) - => Assert.IsType(evaluation.TerminalDecision); - - 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, - hasExactOneTimeApproval: false, - candidates: candidates); - - private static ShellPolicyEvaluation CreateEvaluationWithExactOneTime( - bool isMessy, - params ApprovalCandidate[] candidates) - => CreateEvaluation( - isMessy, - hasExactOneTimeApproval: true, - candidates: candidates); - - private static ShellPolicyEvaluation CreateEvaluation( - bool isMessy, - bool hasExactOneTimeApproval, - string cwd = "/work", - params ApprovalCandidate[] candidates) - { - var environment = ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); - var approvalContext = new ToolApprovalContext( - ShellTool.ToolName, - "shell command", - candidates.Select(static candidate => candidate.Verb).ToArray(), - candidates.Select(static candidate => candidate.Verb).ToArray(), - [], - Cwd: cwd, - IsMessy: isMessy, - Candidates: candidates); - var context = TestToolExecutionContext.CreateBound( - "signalr/shell-policy-evaluation", - "/work/session", - TrustAudience.Personal); - if (hasExactOneTimeApproval) - { - context.OneTimeApprovedToolName = ShellTool.ToolName; - context.SetOneTimeApprovedPatterns(OneTimeApprovalKeys.Create(approvalContext)); - } - - var created = ShellPolicyProjection.TryCreate( - environment, - new ShellApprovalMatcher(environment), - execution: null, - approvalContext, - context, - static _ => false, - out var projection); - - Assert.True(created); - Assert.NotNull(projection); - return new ShellPolicyEvaluation(projection); - } - - private static ( - ShellPolicyEvaluation Evaluation, - ToolAccessPolicy Policy, - ToolExecutionContext Context) CreateCausalEvaluation( - string command, - IEnumerable? deniedPaths = null, - SafeVerbList? safeVerbs = null) - { - var environment = ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); - var commandPolicy = new ShellCommandPolicy(environment); - var pathPolicy = new ToolPathPolicy(environment, deniedPaths ?? []); - var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; - var policy = new ToolAccessPolicy( - config, - new EffectivePolicyDefaults( - DeploymentPosture.Personal, - TrustAudience.Personal, - ShellExecutionMode.HostAllowed, - UsedStrictFallback: false), - commandPolicy, - pathPolicy, - safeVerbs: safeVerbs); - var approvalContext = new ToolApprovalContext( - ShellTool.ToolName, - "shell command", - [], - [], - [], - Cwd: "/work", - IsMessy: true, - Candidates: []); - var context = TestToolExecutionContext.CreateBound( - "signalr/shell-policy-causal-stage", - "/work/session", - TrustAudience.Personal); - var execution = commandPolicy.Analyze(command, "/work"); - var created = ShellPolicyProjection.TryCreate( - environment, - new ShellApprovalMatcher(environment), - execution, - approvalContext, - context, - policy.IsSafePlatformTemporaryPath, - out var projection); - - Assert.True(created); - Assert.NotNull(projection); - Assert.True(projection.HasCausalIntent); - return (new ShellPolicyEvaluation(projection), policy, context); - } - - private static ( - ShellPolicyEvaluation Evaluation, - ToolAccessPolicy Policy, - ToolExecutionContext Context) CreateReviewedSafeEvaluation( - string command, - bool interactive, - params string[] safeVerbs) - => CreateReviewedSafeEvaluation( - command, - interactive, - ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux), - ApprovalShell.Bash, - "/work", - safeVerbs); - - private static ( - ShellPolicyEvaluation Evaluation, - ToolAccessPolicy Policy, - ToolExecutionContext Context) CreateReviewedSafeEvaluation( - string command, - bool interactive, - ShellExecutionEnvironment environment, - ApprovalShell approvalShell, - string workingDirectory, - params string[] safeVerbs) - { - var commandPolicy = new ShellCommandPolicy(environment); - var pathPolicy = new ToolPathPolicy(environment, []); - var policy = new ToolAccessPolicy( - new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }, - new EffectivePolicyDefaults( - DeploymentPosture.Personal, - TrustAudience.Personal, - ShellExecutionMode.HostAllowed, - UsedStrictFallback: false), - commandPolicy, - pathPolicy, - safeVerbs: SafeVerbList.FromVerbs(approvalShell, safeVerbs)); - var matcher = new ShellApprovalMatcher(environment); - var arguments = ToolInput.Create( - "Command", - command, - "WorkingDirectory", - workingDirectory); - var execution = commandPolicy.Analyze(command, workingDirectory); - var approval = matcher.AnalyzeInvocation( - new ToolName(ShellTool.ToolName), - arguments, - execution); - var approvalContext = new ToolApprovalContext( - ShellTool.ToolName, - approval.DisplayText, - approval.Patterns, - approval.Candidates.Select(static candidate => candidate.Verb).ToArray(), - [], - Cwd: workingDirectory, - approval.IsMessy, - approval.Candidates); - var context = TestToolExecutionContext.CreateBound( - "signalr/shell-policy-reviewed-safe-stage", - environment.PathStyle == ShellPathStyle.Windows - ? $@"{workingDirectory}\session" - : $"{workingDirectory}/session", - new TestToolExecutionContextOptions - { - Audience = TrustAudience.Personal, - ProjectDirectory = workingDirectory, - InteractiveApproval = TestToolExecutionContext.InteractiveApproval(interactive) - }); - var created = ShellPolicyProjection.TryCreate( - environment, - matcher, - execution, - approvalContext, - context, - static _ => false, - out var projection); - - Assert.True(created); - Assert.NotNull(projection); - return (new ShellPolicyEvaluation(projection), policy, context); - } - - private static ApprovalCandidate BashCandidate(string verb) => new(verb, "/work") - { - Shell = ApprovalShell.Bash, - VerbTokens = Array.AsReadOnly(verb.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - }; - - private static CanonicalShellPath CreateCanonicalPath( - string value, - ShellPathStyle pathStyle) - { - Assert.True(CanonicalShellPath.TryCreate(value, pathStyle, out var path)); - return path; - } - - private sealed class FixedShellApprovalService( - Func responseFactory) - : IToolApprovalService, IShellApprovalMatchService - { - internal int RequestCount { get; private set; } - - public Task MatchShellCandidatesAsync( - ShellApprovalMatchRequest request, - CancellationToken cancellationToken) - { - RequestCount++; - return Task.FromResult(responseFactory(request)); - } - - public Task CheckApprovalAsync( - ToolApprovalSessionId? sessionId, - TrustAudience audience, - ToolName toolName, - IReadOnlyList candidates, - string? cwd, - CancellationToken ct = default) - => throw new InvalidOperationException("The stage must use typed actor evidence."); - - public Task> GetUnapprovedPatternsAsync( - ToolApprovalSessionId? sessionId, - TrustAudience audience, - ToolName toolName, - IReadOnlyList patterns, - string? cwd, - CancellationToken ct = default) - => throw new InvalidOperationException("The stage must use typed actor evidence."); - - public Task RecordApprovalAsync( - ToolApprovalSessionId sessionId, - TrustAudience audience, - ToolName toolName, - IReadOnlyList patterns, - bool persistent, - string? cwd, - CancellationToken ct = default) - => throw new InvalidOperationException("The stage must not record approvals."); - } -} diff --git a/src/Netclaw.Actors.Tests/Tools/ShellPolicyPathFactsTests.cs b/src/Netclaw.Actors.Tests/Tools/ShellPolicyPathFactsTests.cs new file mode 100644 index 000000000..121b544f2 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Tools/ShellPolicyPathFactsTests.cs @@ -0,0 +1,292 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Protocol; +using Netclaw.Actors.Tools; +using Netclaw.Configuration; +using Netclaw.Security; +using Netclaw.Tests.Utilities; +using ShellSyntaxTree; +using Xunit; + +namespace Netclaw.Actors.Tests.Tools; + +public sealed class ShellPolicyPathFactsTests +{ + [Theory] + [InlineData(false, "head /external/file.log", "/work", "/external/file.log")] + [InlineData(true, @"Get-Content C:\external\file.log", @"C:\work", @"C:\external\file.log")] + public void Absolute_paths_are_not_rebased_beneath_the_resolution_base( + bool windowsStyle, + string command, + string resolutionBase, + string expected) + { + var environment = windowsStyle + ? ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7) + : ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); + var occurrence = Assert.Single( + new ShellCommandPolicy(environment).Analyze(command, resolutionBase).Commands); + + var facts = ShellPolicyOccurrencePathFacts.Create(occurrence).Resolve( + resolutionBase, + environment.PathStyle, + windowsStyle ? ApprovalShell.PowerShell : ApprovalShell.Bash); + + Assert.Contains( + facts.Facts, + fact => fact.Source.Origin == ShellPolicyPathOrigin.EffectiveArgument + && fact.State == ShellPolicyPathResolutionState.Known + && fact.Paths.Any(path => path.Value == expected)); + } + + [Theory] + [InlineData(@"\external\file.log")] + [InlineData(@"D:file.log")] + [InlineData(@"FileSystem::C:\external\file.log")] + public void Ambiguous_windows_root_forms_remain_strict(string value) + { + Assert.False(ShellPolicyOccurrencePathFacts.TryResolveCanonicalPath( + value, + @"C:\work", + ShellPathStyle.Windows, + out _)); + } + + [Fact] + public void Candidate_scope_remains_separate_from_the_command_base() + { + var environment = ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); + var occurrence = Assert.Single( + new ShellCommandPolicy(environment) + .Analyze("cat /work/sub/file.txt", "/work") + .Commands); + var candidate = Candidate( + occurrence, + directory: "/work/sub", + ApprovalShell.Bash, + "cat"); + + var facts = Assert.Single(ShellPolicyPathFacts.Create( + [candidate], + ShellPathStyle.Posix)); + + Assert.Equal("/work/sub", facts.RealScope.Path?.Value); + Assert.Equal("/work", facts.Real.ResolutionBase.Path?.Value); + Assert.Contains( + facts.Real.Facts, + fact => fact.Source.Origin == ShellPolicyPathOrigin.EffectiveArgument + && fact.Paths.Any(path => path.Value == "/work/sub/file.txt")); + } + + [Fact] + public void Intent_and_fallback_resolutions_remain_distinct() + { + var environment = ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); + var occurrence = Assert.Single( + new ShellCommandPolicy(environment).Analyze("head result.log", "/work").Commands); + var candidate = Candidate( + occurrence, + directory: "/work", + ApprovalShell.Bash, + "head") with + { + Role = ShellPolicyCandidateRole.CausalIntentConsumer, + IntentDirectory = "/tmp", + IntentFallbackDirectories = ["/work"] + }; + + var facts = Assert.Single(ShellPolicyPathFacts.Create( + [candidate], + ShellPathStyle.Posix)); + + Assert.Equal("/tmp", facts.Intent?.ResolutionBase.Path?.Value); + Assert.Equal("/work", Assert.Single(facts.Fallbacks).ResolutionBase.Path?.Value); + Assert.Contains( + Assert.IsType(facts.Intent).Facts, + fact => fact.Paths.Any(path => path.Value == "/tmp/result.log")); + Assert.Contains( + Assert.Single(facts.Fallbacks).Facts, + fact => fact.Paths.Any(path => path.Value == "/work/result.log")); + } + + [Theory] + [InlineData(null, nameof(ShellPolicyPathResolutionState.UnknownDynamic))] + [InlineData("relative", nameof(ShellPolicyPathResolutionState.InvalidKnownValue))] + [InlineData("/work", nameof(ShellPolicyPathResolutionState.Known))] + public void Scope_resolution_distinguishes_unknown_invalid_and_known( + string? value, + string expectedName) + { + var expected = Enum.Parse(expectedName); + var scope = ShellPolicyPathFacts.ResolveScope(value, ShellPathStyle.Posix); + + Assert.Equal(expected, scope.State); + Assert.Equal(expected == ShellPolicyPathResolutionState.Known, scope.Path is not null); + } + + [Fact] + public void Dynamic_redirects_remain_unknown_instead_of_invalid() + { + var environment = ShellExecutionEnvironment.CreatePowerShell( + @"C:\Program Files\PowerShell\7\pwsh.exe", + PwshDialect.PowerShell7); + var occurrence = Assert.Single( + new ShellCommandPolicy(environment) + .Analyze("Get-Date > $name", @"C:\work") + .Commands); + + var facts = ShellPolicyOccurrencePathFacts.Create(occurrence).Resolve( + @"C:\work", + ShellPathStyle.Windows, + ApprovalShell.PowerShell); + + Assert.Contains( + facts.Facts, + fact => fact.Source.Origin == ShellPolicyPathOrigin.Redirect + && fact.Source.Domain is ShellValueDomain.Unknown + && fact.State == ShellPolicyPathResolutionState.UnknownDynamic); + Assert.DoesNotContain( + facts.Facts, + static fact => fact.State == ShellPolicyPathResolutionState.InvalidKnownValue); + } + + [Fact] + public void Redirects_retain_mode_completeness_and_domain() + { + var environment = ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); + var occurrence = Assert.Single( + new ShellCommandPolicy(environment) + .Analyze("cat input.txt > output.txt", "/work") + .Commands); + + var facts = ShellPolicyOccurrencePathFacts.Create(occurrence).Resolve( + "/work", + ShellPathStyle.Posix, + ApprovalShell.Bash); + var redirect = Assert.Single( + facts.Facts, + static fact => fact.Source.Origin == ShellPolicyPathOrigin.Redirect); + + Assert.Equal(FileRedirectMode.Output, redirect.Source.RedirectMode); + Assert.True(redirect.Source.RedirectIsComplete); + Assert.IsType(redirect.Source.Domain); + Assert.Equal(ShellPolicyPathResolutionState.Known, redirect.State); + Assert.Equal("/work/output.txt", Assert.Single(redirect.Paths).Value); + } + + [Fact] + public void Uncovered_context_is_recomputed_for_coverage_and_session_scope() + { + var evaluation = CreateEvaluation( + BashCandidate("git status", "/work/repo"), + BashCandidate("git push", "/work/repo")); + var sessionScratch = evaluation.GetUncoveredApprovalContext("/work/repo"); + + evaluation.Cover(evaluation.Candidates[0], ShellPolicyCoverageSource.Session); + var remaining = evaluation.GetUncoveredApprovalContext("/work/session"); + + Assert.NotSame(sessionScratch, remaining); + Assert.Equal([evaluation.Candidates[1].Candidate], remaining.Candidates); + Assert.DoesNotContain( + sessionScratch.Options, + static option => option.Key == ApprovalOptionKeys.ApproveAlwaysKey); + Assert.Contains( + remaining.Options, + static option => option.Key == ApprovalOptionKeys.ApproveAlwaysKey); + } + + [Theory] + [InlineData("duplicate")] + [InlineData("identity")] + [InlineData("id")] + [InlineData("coverage")] + [InlineData("timestamp")] + public void Invalid_coverage_mutations_are_atomic(string mutation) + { + var evaluation = CreateEvaluation(BashCandidate("git status", "/work")); + var candidate = Assert.Single(evaluation.Candidates); + if (mutation == "duplicate") + evaluation.Cover(candidate, ShellPolicyCoverageSource.Session); + + Action apply = mutation switch + { + "duplicate" => () => evaluation.Cover( + candidate, + ShellPolicyCoverageSource.PersistentGlobal), + "identity" => () => evaluation.Cover( + candidate with { Candidate = BashCandidate("git push", "/work") }, + ShellPolicyCoverageSource.Session), + "id" => () => evaluation.Cover( + candidate with { Id = new ShellPolicyCandidateId(7) }, + ShellPolicyCoverageSource.Session), + "coverage" => () => evaluation.Cover( + candidate, + (ShellPolicyCoverageSource)999), + "timestamp" => () => evaluation.Cover( + candidate, + ShellPolicyCoverageSource.Session, + new DateTimeOffset(2026, 8, 14, 0, 0, 0, TimeSpan.Zero)), + _ => throw new ArgumentOutOfRangeException(nameof(mutation)) + }; + + Assert.Throws(apply); + + Assert.Equal( + mutation == "duplicate" + ? ShellPolicyCoverageSource.Session + : ShellPolicyCoverageSource.Uncovered, + evaluation.CoverageFor(candidate.Id)); + } + + private static ShellPolicyCandidate Candidate( + CommandOccurrence occurrence, + string directory, + ApprovalShell shell, + params string[] verbTokens) + => new( + new ShellPolicyCandidateId(0), + new ApprovalCandidate(string.Join(' ', verbTokens), directory) + { + Shell = shell, + VerbTokens = Array.AsReadOnly(verbTokens) + }, + occurrence); + + private static ShellPolicyEvaluation CreateEvaluation(params ApprovalCandidate[] candidates) + { + var environment = ShellExecutionEnvironment.CreateBash(ShellPlatform.Linux); + var approvalContext = new ToolApprovalContext( + ShellTool.ToolName, + "shell command", + candidates.Select(static candidate => candidate.Verb).ToArray(), + candidates.Select(static candidate => candidate.Verb).ToArray(), + [], + Cwd: "/work/repo", + Candidates: candidates); + var context = TestToolExecutionContext.CreateBound( + "signalr/shell-policy-path-facts", + "/work/session", + TrustAudience.Personal); + + Assert.True(ShellPolicyProjection.TryCreate( + environment, + new ShellApprovalMatcher(environment), + execution: null, + approvalContext, + context, + static _ => false, + out var projection)); + return new ShellPolicyEvaluation(Assert.IsType(projection)); + } + + private static ApprovalCandidate BashCandidate(string verb, string directory) => new(verb, directory) + { + Shell = ApprovalShell.Bash, + VerbTokens = Array.AsReadOnly(verb.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + }; +} diff --git a/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs b/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs index c618b9cc6..64250e5ec 100644 --- a/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs +++ b/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs @@ -131,7 +131,7 @@ private async Task CompleteAsync( var evaluation = new ShellPolicyEvaluation(projection); try { - return await CompleteStagesAsync( + return await EvaluatePolicyAsync( tool, toolCall, context, @@ -145,203 +145,62 @@ private async Task CompleteAsync( catch (Exception) { cancellationToken.ThrowIfCancellationRequested(); - evaluation.InvalidateStage(ShellPolicyFault.StageException); - return CompleteEvaluation(evaluation); + return evaluation.InternalFailure(); } } - private async Task CompleteStagesAsync( + private async Task EvaluatePolicyAsync( INetclawTool tool, FunctionCallContent toolCall, ToolExecutionContext context, ShellPolicyEvaluation evaluation, CancellationToken cancellationToken) { - bool Continue(ShellPolicyStageOutcome outcome) - { - cancellationToken.ThrowIfCancellationRequested(); - return evaluation.ApplyStageOutcome(outcome); - } - - 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, - _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)) - || !Continue( - ShellPolicyGrantStages.PersistentStoreAvailability(evaluation)) - || !Continue( - ShellPolicyTerminalStage.Complete(evaluation, context))) - { - return CompleteEvaluation(evaluation); - } - - evaluation.InvalidateStage(ShellPolicyFault.InvalidStageResult); - - return CompleteEvaluation(evaluation); - } - - private static ToolAuthorizationDecision Complete( - ToolAccessDecision decision, - IReadOnlyList approvalMatches, - ShellPolicyDecisionTraceBuilder trace) - => CompleteWithTrace( - ToolAuthorizationDecision.From(decision, approvalMatches), - trace); - - private static ToolAuthorizationDecision CompleteWithTrace( - ToolAuthorizationDecision decision, - ShellPolicyDecisionTraceBuilder trace) - => decision.WithShellPolicyTrace(trace.Complete(decision)); - - private static ToolAuthorizationDecision CompleteEvaluation(ShellPolicyEvaluation evaluation) - { - var decision = evaluation.TerminalDecision - ?? throw new InvalidOperationException("Shell policy stage did not set a decision."); - var completedTrace = evaluation.CompletedTrace - ?? throw new InvalidOperationException("Shell policy stage did not complete its trace."); - return decision.WithShellPolicyTrace(completedTrace); - } - - private static ToolApprovalSessionId? ToApprovalSessionId(string? sessionId) - => sessionId is null ? null : (ToolApprovalSessionId)sessionId; -} - -internal static class ShellPolicyInitialStages -{ - internal static ShellPolicyStageOutcome Syntax( - ShellPolicyEvaluation evaluation, - string toolName) - { - ArgumentNullException.ThrowIfNull(evaluation); - ArgumentException.ThrowIfNullOrWhiteSpace(toolName); var projection = evaluation.Projection; - if (projection.ApprovalContext.IsMessy && !projection.HasCausalIntent) - return CreateOneTimeOrPrompt(evaluation, toolName); - - if (projection.Candidates.Count == 0) - return CreateOneTimeOrPrompt(evaluation, toolName); - - var expectedShell = projection.Environment.Grammar == ShellGrammar.Bash - ? ApprovalShell.Bash - : ApprovalShell.PowerShell; - if (projection.Candidates.Any(static candidate => + if (projection.ApprovalContext.IsMessy && !projection.HasCausalIntent + || projection.Candidates.Count == 0 + || projection.Candidates.Any(static candidate => candidate.Candidate.Shell is null || candidate.Candidate.VerbTokens is null)) { - return CreateOneTimeOrPrompt(evaluation, toolName); + return CompleteOneTimeOrPrompt(evaluation, toolCall.Name); } + var expectedShell = projection.Environment.Grammar == ShellGrammar.Bash + ? ApprovalShell.Bash + : ApprovalShell.PowerShell; 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 evaluation.Fault(ShellPolicyFault.InvalidProjection); + throw new InvalidOperationException("Invalid shell policy projection."); } + cancellationToken.ThrowIfCancellationRequested(); - return ShellPolicyStageOutcome.Continue; - } - - internal static ShellPolicyStageOutcome 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[candidate.Id.Value])) - ? evaluation.Complete( - ToolAuthorizationDecision.Deny("shell_references_protected_path")) - : ShellPolicyStageOutcome.Continue; - } - - internal static ShellPolicyStageOutcome 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, toolName) - : ShellPolicyStageOutcome.Continue; - } - - private static ShellPolicyStageOutcome CreateOneTimeOrPrompt( - ShellPolicyEvaluation evaluation, - string toolName) - { - var projection = evaluation.Projection; - if (!projection.HasExactOneTimeApproval(toolName, projection.ApprovalContext)) + if (projection.Candidates.Any(candidate => + candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer + && policy.CausalIntentReferencesProtectedPath( + projection.PathFacts[candidate.Id.Value]))) { return evaluation.Complete( - ToolAuthorizationDecision.RequiresApproval(projection.ApprovalContext)); + ToolAuthorizationDecision.Deny("shell_references_protected_path")); } + cancellationToken.ThrowIfCancellationRequested(); - return evaluation.Complete( - ToolAuthorizationDecision.Allow(ToolAllowReason.OneTimeApproval), - allowsUncoveredOneTime: true); - } -} + if (projection.Candidates.Any(candidate => + candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer + && candidate.IntentDirectory is { } intentDirectory + && !policy.AreCausalIntentDirectoriesEligible( + intentDirectory, + candidate.IntentFallbackDirectories))) + { + return CompleteOneTimeOrPrompt(evaluation, toolCall.Name); + } + cancellationToken.ThrowIfCancellationRequested(); -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( @@ -349,15 +208,16 @@ internal static async ValueTask ActorEvidenceAsync( candidate.Candidate, projection.ApprovalContext.Cwd)) .ToArray(); - var actorResult = await approvalEvidence.MatchAsync( + var actorResult = await _approvalEvidence.MatchAsync( new ShellApprovalMatchRequest( - sessionId, - audience, - toolName, + ToApprovalSessionId(context.SessionId), + context.Audience, + new ToolName(tool.Name), projection.Environment, Array.AsReadOnly(requestCandidates)), projection.ApprovalContext.Cwd, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); if (!ValidatedShellGrantEvidence.TryCreate( actorResult, grantCandidates, @@ -365,90 +225,61 @@ internal static async ValueTask ActorEvidenceAsync( out var grantEvidence) || grantEvidence is null) { - return evaluation.Fault(ShellPolicyFault.InvalidActorEvidence); + throw new InvalidOperationException("Invalid shell approval evidence."); } - return evaluation.ApplyActorEvidence(grantEvidence); - } - - internal static ShellPolicyStageOutcome ApprovalExemptSideEffects( - ShellPolicyEvaluation evaluation, - bool approvalEvidenceAvailable) - { - ArgumentNullException.ThrowIfNull(evaluation); - if (!approvalEvidenceAvailable) - return ShellPolicyStageOutcome.Continue; - - foreach (var candidate in evaluation.Candidates.Where(static item => - item.Role == ShellPolicyCandidateRole.Ordinary - && ApprovalPatternMatching.IsPureSideEffect(item.Candidate))) + evaluation.ApplyActorEvidence(grantEvidence); + cancellationToken.ThrowIfCancellationRequested(); + if (_approvalEvidence.IsAvailable) { - var result = evaluation.Cover( - candidate, - ShellPolicyCoverageSource.ApprovalExemptSideEffect); - if (result != ShellPolicyStageOutcome.Continue) - return result; + foreach (var candidate in evaluation.Candidates.Where(static item => + item.Role == ShellPolicyCandidateRole.Ordinary + && ApprovalPatternMatching.IsPureSideEffect(item.Candidate))) + { + evaluation.Cover( + candidate, + ShellPolicyCoverageSource.ApprovalExemptSideEffect); + } } + cancellationToken.ThrowIfCancellationRequested(); - return ShellPolicyStageOutcome.Continue; - } + if (projection.RunScope.InteractiveApproval + is InteractiveApprovalCapability.Available) + { + ApplyReviewedSafeCoverage(evaluation, policy, context.Invocation); + } + cancellationToken.ThrowIfCancellationRequested(); - internal static ShellPolicyStageOutcome ExactOneTime( - ShellPolicyEvaluation evaluation, - ToolName toolName, - string? sessionDirectory) - { - ArgumentNullException.ThrowIfNull(evaluation); - ArgumentException.ThrowIfNullOrWhiteSpace(toolName.Value); var uncovered = evaluation.UncoveredCandidates; - if (uncovered.Count == 0) - return ShellPolicyStageOutcome.Continue; - - var remainingContext = evaluation.GetUncoveredApprovalContext(sessionDirectory); - if (!evaluation.Projection.HasExactOneTimeApproval(toolName.Value, remainingContext)) - return ShellPolicyStageOutcome.Continue; - - foreach (var candidate in uncovered) + if (uncovered.Count > 0) { - var result = evaluation.Cover( - candidate, - ShellPolicyCoverageSource.OneTime); - if (result != ShellPolicyStageOutcome.Continue) - return result; + var remainingContext = evaluation.GetUncoveredApprovalContext( + context.SessionDirectory); + if (projection.HasExactOneTimeApproval(toolCall.Name, remainingContext)) + { + foreach (var candidate in uncovered) + evaluation.Cover(candidate, ShellPolicyCoverageSource.OneTime); + } } + cancellationToken.ThrowIfCancellationRequested(); - return ShellPolicyStageOutcome.Continue; - } - - internal static ShellPolicyStageOutcome PersistentStoreAvailability( - ShellPolicyEvaluation evaluation) - { - ArgumentNullException.ThrowIfNull(evaluation); - if (evaluation.GrantEvidence is null) - return evaluation.Fault(ShellPolicyFault.InvalidActorEvidence); + if (evaluation.UncoveredCandidates.Count > 0 + && evaluation.GrantEvidence?.PersistentStore + is PersistentGrantStoreStatus.Unavailable) + { + return evaluation.Complete( + ToolAuthorizationDecision.Deny("approval_store_unavailable")); + } - return evaluation.UncoveredCandidates.Count > 0 - && evaluation.GrantEvidence.PersistentStore - is PersistentGrantStoreStatus.Unavailable - ? evaluation.Complete( - ToolAuthorizationDecision.Deny("approval_store_unavailable")) - : ShellPolicyStageOutcome.Continue; + cancellationToken.ThrowIfCancellationRequested(); + return CompleteFinal(evaluation, context); } -} -internal static class ShellPolicyReviewedSafeStages -{ - internal static ShellPolicyStageOutcome RealScope( + private static void ApplyReviewedSafeCoverage( ShellPolicyEvaluation evaluation, ToolAccessPolicy policy, ToolInvocationContext invocation) { - ArgumentNullException.ThrowIfNull(evaluation); - ArgumentNullException.ThrowIfNull(policy); - ArgumentNullException.ThrowIfNull(invocation); - if (!CanUseReviewedSafePolicy(evaluation)) - return ShellPolicyStageOutcome.Continue; - foreach (var candidate in evaluation.Projection.GrantCandidates.Where(candidate => candidate.CanUseRealReviewedSafePolicy && !evaluation.IsCovered(candidate.Id))) @@ -461,27 +292,11 @@ internal static ShellPolicyStageOutcome RealScope( continue; } - var result = evaluation.Cover( + evaluation.Cover( candidate, ShellPolicyCoverageSource.ReviewedSafeReal); - if (result != ShellPolicyStageOutcome.Continue) - return result; } - return ShellPolicyStageOutcome.Continue; - } - - internal static ShellPolicyStageOutcome IntentScope( - ShellPolicyEvaluation evaluation, - ToolAccessPolicy policy, - ToolInvocationContext invocation) - { - ArgumentNullException.ThrowIfNull(evaluation); - ArgumentNullException.ThrowIfNull(policy); - ArgumentNullException.ThrowIfNull(invocation); - if (!CanUseReviewedSafePolicy(evaluation)) - return ShellPolicyStageOutcome.Continue; - foreach (var candidate in evaluation.Candidates.Where(candidate => candidate.Role == ShellPolicyCandidateRole.CausalIntentConsumer && !evaluation.IsCovered(candidate.Id))) @@ -498,29 +313,16 @@ internal static ShellPolicyStageOutcome IntentScope( continue; } - var result = evaluation.Cover( + evaluation.Cover( candidate, ShellPolicyCoverageSource.ReviewedSafeIntent); - if (result != ShellPolicyStageOutcome.Continue) - return result; } - - return ShellPolicyStageOutcome.Continue; } - private static bool CanUseReviewedSafePolicy(ShellPolicyEvaluation evaluation) - => evaluation.Projection.RunScope.InteractiveApproval - is InteractiveApprovalCapability.Available; -} - -internal static class ShellPolicyTerminalStage -{ - internal static ShellPolicyStageOutcome Complete( + private static ToolAuthorizationDecision CompleteFinal( ShellPolicyEvaluation evaluation, ToolExecutionContext context) { - ArgumentNullException.ThrowIfNull(evaluation); - ArgumentNullException.ThrowIfNull(context); var projection = evaluation.Projection; var approvalMatches = evaluation.ApprovalMatches; var uncovered = evaluation.UncoveredCandidates; @@ -572,4 +374,33 @@ internal static ShellPolicyStageOutcome Complete( private static string FormatApprovalMatches(IReadOnlyList matches) => string.Join(", ", matches.Select(match => $"{match.Pattern} [{match.Source}: {match.Scope}]")); + + private static ToolAuthorizationDecision CompleteOneTimeOrPrompt( + ShellPolicyEvaluation evaluation, + string toolName) + { + var projection = evaluation.Projection; + return projection.HasExactOneTimeApproval(toolName, projection.ApprovalContext) + ? evaluation.Complete( + ToolAuthorizationDecision.Allow(ToolAllowReason.OneTimeApproval), + allowsUncoveredOneTime: true) + : evaluation.Complete( + ToolAuthorizationDecision.RequiresApproval(projection.ApprovalContext)); + } + + private static ToolAuthorizationDecision Complete( + ToolAccessDecision decision, + IReadOnlyList approvalMatches, + ShellPolicyDecisionTraceBuilder trace) + => CompleteWithTrace( + ToolAuthorizationDecision.From(decision, approvalMatches), + trace); + + private static ToolAuthorizationDecision CompleteWithTrace( + ToolAuthorizationDecision decision, + ShellPolicyDecisionTraceBuilder trace) + => decision.WithShellPolicyTrace(trace.Complete(decision)); + + private static ToolApprovalSessionId? ToApprovalSessionId(string? sessionId) + => sessionId is null ? null : (ToolApprovalSessionId)sessionId; } diff --git a/src/Netclaw.Actors/Tools/ShellPolicyEvaluation.cs b/src/Netclaw.Actors/Tools/ShellPolicyEvaluation.cs index 60fe4f11a..4cb5c05f1 100644 --- a/src/Netclaw.Actors/Tools/ShellPolicyEvaluation.cs +++ b/src/Netclaw.Actors/Tools/ShellPolicyEvaluation.cs @@ -7,19 +7,6 @@ namespace Netclaw.Actors.Tools; -internal enum ShellPolicyFault -{ - InvalidCandidateId = 0, - CandidateFactsChanged = 1, - InvalidCoverage = 2, - CoverageAlreadyAssigned = 3, - InvalidTerminalTransition = 4, - InvalidStageResult = 5, - StageException = 6, - InvalidProjection = 7, - InvalidActorEvidence = 8, -} - internal abstract record ShellPolicyPreflightResult { private ShellPolicyPreflightResult() @@ -74,13 +61,6 @@ internal Continue( } } -internal enum ShellPolicyStageOutcome -{ - Invalid = 0, - Continue = 1, - Complete = 2, -} - internal sealed record ShellPolicyAuthorization { internal ShellPolicyAuthorization( @@ -109,9 +89,6 @@ internal sealed class ShellPolicyEvaluation { private readonly ShellPolicyCoverageSource[] _coverage; private readonly ShellPolicyDecisionTraceBuilder _trace = new(); - private ToolAuthorizationDecision? _terminalDecision; - private ShellPolicyDecisionTrace? _completedTrace; - private ShellPolicyFault? _terminalFault; private ValidatedShellGrantEvidence? _grantEvidence; private (string? SessionDirectory, ToolApprovalContext Context)? _uncoveredApprovalContext; @@ -164,12 +141,6 @@ internal ToolApprovalContext GetUncoveredApprovalContext(string? sessionDirector return context; } - internal ToolAuthorizationDecision? TerminalDecision => _terminalDecision; - - internal ShellPolicyDecisionTrace? CompletedTrace => _completedTrace; - - internal ShellPolicyFault? TerminalFault => _terminalFault; - internal ShellPolicyCoverageSource CoverageFor(ShellPolicyCandidateId candidateId) { var index = candidateId.Value; @@ -184,15 +155,14 @@ internal bool IsCovered(ShellPolicyCandidateId candidateId) return CoverageFor(candidateId) != ShellPolicyCoverageSource.Uncovered; } - internal ShellPolicyStageOutcome ApplyActorEvidence(ValidatedShellGrantEvidence evidence) + internal void ApplyActorEvidence(ValidatedShellGrantEvidence evidence) { ArgumentNullException.ThrowIfNull(evidence); - if (_terminalDecision is not null) - return ShellPolicyStageOutcome.Complete; - if (_grantEvidence is not null || !ReferenceEquals(evidence.SourceCandidates, Projection.GrantCandidates)) - return Fail(ShellPolicyFault.InvalidActorEvidence); + { + throw new InvalidOperationException("Invalid shell approval evidence."); + } _grantEvidence = evidence; foreach (var candidateEvidence in evidence.CandidateEvidence) @@ -200,12 +170,10 @@ internal ShellPolicyStageOutcome ApplyActorEvidence(ValidatedShellGrantEvidence var actorEvidence = candidateEvidence.ActorEvidence; if (actorEvidence.GrantCoverage is { } grantCoverage) { - var result = Cover( + Cover( candidateEvidence.Candidate, ToCoverageSource(grantCoverage), actorEvidence.GrantCreatedAt); - if (result != ShellPolicyStageOutcome.Continue) - return result; } else { @@ -213,28 +181,24 @@ internal ShellPolicyStageOutcome ApplyActorEvidence(ValidatedShellGrantEvidence } } - return ShellPolicyStageOutcome.Continue; } - internal ShellPolicyStageOutcome Cover( + internal void Cover( ShellPolicyCandidate candidate, ShellPolicyCoverageSource source, DateTimeOffset? grantTimestamp = null) { ArgumentNullException.ThrowIfNull(candidate); - if (_terminalDecision is not null) - return ShellPolicyStageOutcome.Complete; - var index = candidate.Id.Value; if ((uint)index >= (uint)Candidates.Count) - return Fail(ShellPolicyFault.InvalidCandidateId); + throw new InvalidOperationException("Invalid shell candidate ID."); if (!ReferenceEquals(candidate, Projection.Candidates[index])) - return Fail(ShellPolicyFault.CandidateFactsChanged); + throw new InvalidOperationException("Shell candidate facts changed."); if (_coverage[index] != ShellPolicyCoverageSource.Uncovered) - return Fail(ShellPolicyFault.CoverageAlreadyAssigned); + throw new InvalidOperationException("Shell candidate coverage was assigned twice."); if (!Enum.IsDefined(source) || source == ShellPolicyCoverageSource.Uncovered @@ -243,24 +207,20 @@ internal ShellPolicyStageOutcome Cover( (ShellPolicyCoverageSource.PersistentGlobal or ShellPolicyCoverageSource.PersistentFolder)) { - return Fail(ShellPolicyFault.InvalidCoverage); + throw new InvalidOperationException("Invalid shell candidate coverage."); } _trace.AddCoverage(source, candidate, grantTimestamp); _coverage[index] = source; _uncoveredApprovalContext = null; - return ShellPolicyStageOutcome.Continue; } - internal ShellPolicyStageOutcome Complete( + internal ToolAuthorizationDecision Complete( ToolAuthorizationDecision decision, bool allowsUncoveredOneTime = false) { ArgumentNullException.ThrowIfNull(decision); - if (_terminalDecision is not null) - return ShellPolicyStageOutcome.Complete; - var mayComplete = decision.Outcome switch { ToolAuthorizationOutcome.Allowed => AllCovered @@ -271,58 +231,13 @@ internal ShellPolicyStageOutcome Complete( _ => false, }; if (!mayComplete) - return Fail(ShellPolicyFault.InvalidTerminalTransition); + throw new InvalidOperationException("Invalid shell terminal decision."); - _completedTrace = _trace.Complete(decision); - _terminalDecision = decision; - return ShellPolicyStageOutcome.Complete; + return decision.WithShellPolicyTrace(_trace.Complete(decision)); } - internal bool ApplyStageOutcome(ShellPolicyStageOutcome outcome) - { - switch (outcome) - { - case ShellPolicyStageOutcome.Continue when _terminalDecision is null: - return true; - case ShellPolicyStageOutcome.Complete when _terminalDecision is not null: - return false; - default: - InvalidateStage(ShellPolicyFault.InvalidStageResult); - return false; - } - } - - internal ShellPolicyStageOutcome Fault(ShellPolicyFault reason) - { - if (!Enum.IsDefined(reason)) - reason = ShellPolicyFault.InvalidStageResult; - - if (_terminalFault is not null) - return ShellPolicyStageOutcome.Complete; - - if (_terminalDecision is not null) - return ShellPolicyStageOutcome.Complete; - - return Fail(reason); - } - - internal ShellPolicyStageOutcome InvalidateStage(ShellPolicyFault reason) => - Fail( - Enum.IsDefined(reason) ? reason : ShellPolicyFault.InvalidStageResult, - replaceCompletion: true); - - private ShellPolicyStageOutcome Fail( - ShellPolicyFault reason, - bool replaceCompletion = false) - { - var decision = ToolAuthorizationDecision.Deny("internal_policy_failure"); - _completedTrace = replaceCompletion - ? _trace.ReplaceCompletion(decision) - : _trace.Complete(decision); - _terminalDecision = decision; - _terminalFault = reason; - return ShellPolicyStageOutcome.Complete; - } + internal ToolAuthorizationDecision InternalFailure() => Complete( + ToolAuthorizationDecision.Deny("internal_policy_failure")); private static ShellPolicyCoverageSource ToCoverageSource(ShellCoverageKind coverage) => coverage switch