diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1e803f4a7..6602c0f55 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -182,6 +182,12 @@ Done when: - [ ] The policy pipeline replaces the shell branches in `ToolAccessPolicy` and `ShellApprovalMatcher`; any retained legacy scan is deny-only and cannot authorize, create candidates, or widen scope. +- [x] Shell calls pass through one coordinator. It snapshots immutable parser + and run-scope facts, requests one typed actor batch, and composes grant and + reviewed-safe coverage per candidate before one final result. +- [x] The actor response preserves stable candidate IDs and typed persistent + store status. Duplicate IDs, mismatched facts, impossible grant states, and + internal stage faults deny without a prompt. - [ ] The bundled safe catalog removes every executable whose accepted arguments can write, delete, execute code, or mutate a remote service through executable argv interpretation. Redirect, parser-owned path/provider, and @@ -245,12 +251,12 @@ Done when: and path-shape facts introduced in 0.3.1. This store-v3 slice preserves those parser token facts without executable-private command rules; later parent tasks consume the new value-domain facts in the coordinator. -- [ ] Netclaw consumes public ShellSyntaxTree `0.3.3` for the parser-owned +- [x] Netclaw consumes public ShellSyntaxTree `0.3.3` for the parser-owned authored filesystem domain. Local code accepts only `Exact` and `FiniteSet`. It checks each value through path policy and keeps unsafe transforms strict. The Release build and all 7,138 runnable tests pass. The suite reports 15 - expected platform or opt-in skips. Adversarial review and CI remain before - completion. + expected platform or opt-in skips. Adversarial review and all required CI + checks passed before merge. - [x] The expanded 247-test matrix covers command-substitution and PowerShell execution-region behavior. Known command-owned regions reuse independently matched host and body grants after Netclaw accounts for the parsed body. diff --git a/openspec/changes/structure-shell-approval-policy/tasks.md b/openspec/changes/structure-shell-approval-policy/tasks.md index 35d6928e7..0d5bad80e 100644 --- a/openspec/changes/structure-shell-approval-policy/tasks.md +++ b/openspec/changes/structure-shell-approval-policy/tasks.md @@ -16,17 +16,17 @@ ## 2. Typed coordinator and actor protocol -- [ ] 2.1 Snapshot immutable preflight facts from existing +- [x] 2.1 Snapshot immutable preflight facts from existing `ToolExecutionContext`, `ToolRunScope`, `ToolApprovalAttempt`, and `ShellExecutionEnvironment`; preserve `OneTimeApprovalKeys` exact-set semantics and do not add a parallel context or scalar retry key. -- [ ] 2.2 Add one coordinator that runs synchronous preflight, sends one actor +- [x] 2.2 Add one coordinator that runs synchronous preflight, sends one actor batch request, and completes policy without a second grant scan. -- [ ] 2.3 Add `ShellApprovalMatchRequest` and `ShellApprovalMatchResult` to +- [x] 2.3 Add `ShellApprovalMatchRequest` and `ShellApprovalMatchResult` to `ToolApprovalActor`; match inherited session and persistent snapshots atomically, return typed persistent-store status, and leave one-time state in `ToolApprovalAttempt`. -- [ ] 2.4 Route `DispatchingToolExecutor` through the coordinator without +- [x] 2.4 Route `DispatchingToolExecutor` through the coordinator without changing the original source, argument object, or tool history. - [ ] 2.5 Preserve session-pipeline pending-request persistence, stale/duplicate response rejection and recovery; preserve exact-set one-time @@ -38,11 +38,11 @@ candidate construction, noninteractive trust-zone enforcement, actor match, safe policy, exact-set one-time matching, and prompt completion in the specified order. -- [ ] 3.2 Track coverage per candidate; allow only when all candidates are +- [x] 3.2 Track coverage per candidate; allow only when all candidates are covered and call-level invariants pass. -- [ ] 3.3 Make internal exceptions, invalid enums, duplicate candidate IDs, +- [x] 3.3 Make internal exceptions, invalid enums, duplicate candidate IDs, mismatched actor results, and impossible transitions terminal deny. -- [ ] 3.4 Allow fully one-time/session/safe-covered calls when persistent state +- [x] 3.4 Allow fully one-time/session/safe-covered calls when persistent state is unavailable; deny with `ApprovalStoreUnavailable` instead of prompting when any candidate still depends on that state. - [ ] 3.5 Let expected unresolved shell input offer only one-time approval and diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs index 874fbd10a..b4744604b 100644 --- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs @@ -571,7 +571,7 @@ public async Task Authorization_evaluation_preserves_partial_approval_matches() Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); Assert.NotNull(decision.ApprovalContext); Assert.Equal(["git status", "git push"], decision.ApprovalContext.CandidateVerbs); - Assert.Equal([approvedMatch], decision.ApprovalMatches); + Assert.Empty(decision.ApprovalMatches); } [Fact] @@ -636,6 +636,246 @@ public async Task Authorization_evaluation_prompts_only_for_exact_unapproved_can Assert.Equal([approvedMatch], decision.ApprovalMatches); } + [SlopwatchSuppress("SW001", "This test pins the Bash compound-command coverage model used by the Linux approval policy.")] + [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell coverage semantics")] + public async Task Authorization_evaluation_composes_session_and_reviewed_safe_coverage() + { + var root = Path.Combine(Path.GetTempPath(), $"netclaw-coverage-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; + config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig + { + ToolOverrides = new Dictionary(StringComparer.Ordinal) + { + ["shell_execute"] = ToolApprovalMode.Approval + } + }; + var commandPolicy = new ShellCommandPolicy(ShellEnvironment); + var pathPolicy = new ToolPathPolicy(ShellEnvironment, []); + var registry = new ToolRegistry(); + registry.WithFirstPartyTools(config, new NetclawPaths(), pathPolicy, commandPolicy); + var approvalService = new FixedShellApprovalService(request => + { + var matches = request.Candidates.Select(candidate => + { + if (!candidate.Candidate.Verb.StartsWith("git status", StringComparison.Ordinal)) + { + return new ShellGrantCandidateMatch( + candidate.CandidateId, + Match: null, + GrantCoverage: null, + NearMisses: []); + } + + return new ShellGrantCandidateMatch( + candidate.CandidateId, + new ToolApprovalMatch(candidate.Candidate.Verb, "session", "this chat"), + ShellCoverageKind.Session, + []); + }).ToArray(); + return new ShellApprovalMatchResult( + new PersistentGrantStoreStatus.Unavailable(ApprovalStoreFailure.InvalidData), + Array.AsReadOnly(matches)); + }); + var executor = new DispatchingToolExecutor( + registry, + new ToolAccessPolicy( + config, + new EffectivePolicyDefaults( + DeploymentPosture.Personal, + TrustAudience.Personal, + ShellExecutionMode.HostAllowed, + UsedStrictFallback: false), + commandPolicy, + pathPolicy, + safeVerbs: SafeVerbList.FromVerbs(["head"])), + approvalService); + var context = TestToolExecutionContext.CreateBound( + "signalr/mixed-coverage", + sessionDirectory: null, + new TestToolExecutionContextOptions + { + Audience = TrustAudience.Personal, + ProjectDirectory = root, + InteractiveApproval = TestToolExecutionContext.InteractiveApproval(true) + }); + var call = new FunctionCallContent( + "call-mixed-coverage", + "shell_execute", + ToolInput.Create( + "Command", + "git status && head README.md", + "WorkingDirectory", + root)); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + context, + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.Allowed, decision.Outcome); + Assert.Equal(ToolAllowReason.StoredApproval, decision.AllowReason); + Assert.Equal(1, approvalService.RequestCount); + var request = Assert.IsType(approvalService.LastRequest); + Assert.Equal( + Enumerable.Range(0, request.Candidates.Count), + request.Candidates.Select(candidate => candidate.CandidateId.Value)); + Assert.Contains(request.Candidates, candidate => candidate.Candidate.Verb == "git status"); + Assert.Contains(request.Candidates, candidate => candidate.Candidate.Verb == "head"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task Authorization_evaluation_denies_duplicate_actor_candidate_id() + { + var approvalService = new FixedShellApprovalService(request => + { + var duplicateId = request.Candidates[0].CandidateId; + return new ShellApprovalMatchResult( + new PersistentGrantStoreStatus.Ready(), + Array.AsReadOnly(request.Candidates.Select(candidate => + new ShellGrantCandidateMatch( + duplicateId, + Match: null, + GrantCoverage: null, + NearMisses: [])).ToArray())); + }); + var executor = CreateApprovalGatedShellExecutor(approvalService); + var call = new FunctionCallContent( + "call-duplicate-candidate-id", + "shell_execute", + ToolInput.Create("Command", "git status && git push")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/duplicate-candidate-id"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); + Assert.Equal("internal_policy_failure", decision.DenyReason); + } + + [Fact] + public async Task Authorization_evaluation_denies_mismatched_actor_match() + { + var approvalService = new FixedShellApprovalService(request => + new ShellApprovalMatchResult( + new PersistentGrantStoreStatus.Ready(), + Array.AsReadOnly(request.Candidates.Select(candidate => + new ShellGrantCandidateMatch( + candidate.CandidateId, + new ToolApprovalMatch("unrelated", "persistent", "anywhere"), + ShellCoverageKind.Session, + NearMisses: [])).ToArray()))); + var executor = CreateApprovalGatedShellExecutor(approvalService); + var call = new FunctionCallContent( + "call-mismatched-actor-match", + "shell_execute", + ToolInput.Create("Command", "git status")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/mismatched-actor-match"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); + Assert.Equal("internal_policy_failure", decision.DenyReason); + } + + [Theory] + [InlineData("this chat", false)] + [InlineData("garbage anywhere", true)] + public async Task Authorization_evaluation_denies_malformed_persistent_actor_scope( + string scope, + bool claimsGlobalScope) + { + var coverage = claimsGlobalScope + ? ShellCoverageKind.PersistentGlobal + : ShellCoverageKind.PersistentFolder; + var approvalService = new FixedShellApprovalService(request => + new ShellApprovalMatchResult( + new PersistentGrantStoreStatus.Ready(), + Array.AsReadOnly(request.Candidates.Select(candidate => + new ShellGrantCandidateMatch( + candidate.CandidateId, + new ToolApprovalMatch(candidate.Candidate.Verb, "persistent", scope), + coverage, + NearMisses: [])).ToArray()))); + var executor = CreateApprovalGatedShellExecutor(approvalService); + var call = new FunctionCallContent( + "call-malformed-persistent-scope", + "shell_execute", + ToolInput.Create("Command", "git status")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/malformed-persistent-scope"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); + Assert.Equal("internal_policy_failure", decision.DenyReason); + } + + [Fact] + public async Task Authorization_evaluation_denies_invalid_store_failure_enum() + { + var approvalService = new FixedShellApprovalService(request => + new ShellApprovalMatchResult( + new PersistentGrantStoreStatus.Unavailable((ApprovalStoreFailure)999), + Array.AsReadOnly(request.Candidates.Select(candidate => + new ShellGrantCandidateMatch( + candidate.CandidateId, + new ToolApprovalMatch(candidate.Candidate.Verb, "session", "this chat"), + ShellCoverageKind.Session, + NearMisses: [])).ToArray()))); + var executor = CreateApprovalGatedShellExecutor(approvalService); + var call = new FunctionCallContent( + "call-invalid-store-enum", + "shell_execute", + ToolInput.Create("Command", "git status")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/invalid-store-enum"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); + Assert.Equal("internal_policy_failure", decision.DenyReason); + } + + [Fact] + public async Task Authorization_evaluation_denies_uncovered_candidate_when_store_is_unavailable() + { + var approvalService = new FixedShellApprovalService(request => + new ShellApprovalMatchResult( + new PersistentGrantStoreStatus.Unavailable(ApprovalStoreFailure.InvalidData), + 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-unavailable-store-miss", + "shell_execute", + ToolInput.Create("Command", "git push")); + + var decision = await executor.EvaluateAuthorizationAsync( + call, + CreateInteractivePersonalContext("signalr/unavailable-store-miss"), + TestContext.Current.CancellationToken); + + Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); + Assert.Equal("approval_store_unavailable", decision.DenyReason); + } + [SlopwatchSuppress("SW001", "This test verifies Bash parser directory attribution, which does not apply to the Windows shell parser.")] [Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only shell directory semantics")] public async Task Authorization_evaluation_preserves_directory_for_duplicate_verb_candidates() @@ -662,7 +902,11 @@ public async Task Authorization_evaluation_preserves_directory_for_duplicate_ver new NetclawPaths(), new ToolPathPolicy([]), new ShellCommandPolicy()); - var approvedMatch = new ToolApprovalMatch("git push", "persistent", approvedDirectory); + var approvedScope = ApprovalEntry.CreateTokenPrefix( + ApprovalShell.Bash, + ["git", "push"], + approvedDirectory).FormatScope(); + var approvedMatch = new ToolApprovalMatch("git push", "persistent", approvedScope); var approvedCandidate = BashCandidate("git push", approvedDirectory); var unapprovedCandidate = BashCandidate("git push", unapprovedDirectory); var approvalService = new FixedApprovalService( @@ -715,7 +959,7 @@ public async Task Authorization_evaluation_preserves_directory_for_duplicate_ver } [Fact] - public async Task Authorization_evaluation_keeps_broad_prompt_for_inconsistent_candidate_result() + public async Task Authorization_evaluation_denies_inconsistent_candidate_result() { var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig @@ -766,12 +1010,12 @@ public async Task Authorization_evaluation_keeps_broad_prompt_for_inconsistent_c context, TestContext.Current.CancellationToken); - Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); - Assert.Equal(["git status", "git push"], decision.ApprovalContext!.CandidateVerbs); + Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); + Assert.Equal("internal_policy_failure", decision.DenyReason); } [Fact] - public async Task Authorization_evaluation_keeps_broad_prompt_for_inconsistent_parser_tokens() + public async Task Authorization_evaluation_denies_inconsistent_parser_tokens() { var config = new ToolConfig { ShellMode = ShellExecutionMode.HostAllowed }; config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig @@ -826,9 +1070,8 @@ public async Task Authorization_evaluation_keeps_broad_prompt_for_inconsistent_p context, TestContext.Current.CancellationToken); - Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); - Assert.Equal(["git status", "git push"], decision.ApprovalContext!.CandidateVerbs); - Assert.Equal(2, decision.ApprovalContext.Candidates!.Count); + Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); + Assert.Equal("internal_policy_failure", decision.DenyReason); } [Fact] @@ -883,8 +1126,8 @@ public async Task Authorization_evaluation_rejects_inconsistent_all_approved_res context, TestContext.Current.CancellationToken); - Assert.Equal(ToolAuthorizationOutcome.RequiresApproval, decision.Outcome); - Assert.Equal(["git status", "git push"], decision.ApprovalContext!.CandidateVerbs); + Assert.Equal(ToolAuthorizationOutcome.Denied, decision.Outcome); + Assert.Equal("internal_policy_failure", decision.DenyReason); } [Fact] @@ -1925,6 +2168,52 @@ public Task RecordApprovalAsync( => throw new InvalidOperationException("The authorization evaluator must not record an approval."); } + private sealed class FixedShellApprovalService( + Func responseFactory) + : IToolApprovalService, IShellApprovalMatchService + { + public int RequestCount { get; private set; } + + public ShellApprovalMatchRequest? LastRequest { get; private set; } + + public Task MatchShellCandidatesAsync( + ShellApprovalMatchRequest request, + CancellationToken cancellationToken) + { + RequestCount++; + LastRequest = request; + 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 shell coordinator must use the typed batch protocol."); + + public Task> GetUnapprovedPatternsAsync( + ToolApprovalSessionId? sessionId, + TrustAudience audience, + ToolName toolName, + IReadOnlyList patterns, + string? cwd, + CancellationToken ct = default) + => throw new InvalidOperationException("The shell coordinator must use the typed batch protocol."); + + public Task RecordApprovalAsync( + ToolApprovalSessionId sessionId, + TrustAudience audience, + ToolName toolName, + IReadOnlyList patterns, + bool persistent, + string? cwd, + CancellationToken ct = default) + => throw new InvalidOperationException("The authorization evaluator must not record an approval."); + } + private sealed class RecordingLogger : ILogger { public List> Entries { get; } = []; diff --git a/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs b/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs index 5f13c8324..3d3a0b45d 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellApprovalCaseCatalog.cs @@ -178,7 +178,7 @@ internal sealed record ExpectedApproval( { public static ExpectedApproval Allow( ToolAllowReason reason, - int approvalChecks = 0, + int? approvalChecks = null, params string[] approvalMatches) => new( ToolAuthorizationOutcome.Allowed, @@ -186,7 +186,7 @@ public static ExpectedApproval Allow( null, [], null, - approvalChecks, + approvalChecks ?? (reason == ToolAllowReason.SafeVerbInTrustedScope ? 1 : 0), approvalMatches); public static ExpectedApproval Require( @@ -362,13 +362,13 @@ public static class ShellApprovalCases "live-read-chain-with-separator-allows", Bash("rg -rn \"operation failed\" src/ tests/ | head -20; echo \"---\"; rg -rln \"upload\" src/ | head -20"), Approvals.None, - ExpectedApproval.Allow(ToolAllowReason.ApprovalExemptShellCandidates)), + ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), Case( "live-git-diagnostic-chain-with-separators-allows", Bash("git status --short 2>&1 | head; echo \"---branch---\"; git branch --show-current 2>&1; echo \"---remotes---\"; git remote -v 2>&1 | head -4; echo \"---recent---\"; git log --oneline -3 2>&1"), Approvals.None, - ExpectedApproval.Allow(ToolAllowReason.ApprovalExemptShellCandidates)), + ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), Case( "live-finite-url-loop-prompts-with-reusable-phrase", @@ -382,7 +382,7 @@ public static class ShellApprovalCases "gh run view 123456 --repo example/project --log-failed --verbose 2>&1 " + "| head -200; echo \"---EXIT $?---\""), Approvals.None, - ExpectedApproval.Allow(ToolAllowReason.ApprovalExemptShellCandidates)), + ExpectedApproval.Allow(ToolAllowReason.SafeVerbInTrustedScope)), Case( "native-project-path-operand-allows-safe-verb", @@ -1443,12 +1443,17 @@ public static class ShellApprovalCases ExpectedApproval.Allow( ToolAllowReason.StoredApproval, 1, + "session:git status", "persistent:git push")), Case( "partial-compound-grant-prompts", Bash("git status && git push"), Approvals.PersistentAnywhere("git status"), - ExpectedApproval.Require(["git push"])), + ExpectedApproval.Require( + ["git push"], + false, + 1, + "persistent:git status")), Case( "four-unapproved-clauses-prompt", Bash("git add . && git commit -m fix && git push && gh pr merge 123"), diff --git a/src/Netclaw.Actors.Tests/Tools/ShellApprovalDispositionMatrixTests.Shell_approval_cases_match_review_table.verified.md b/src/Netclaw.Actors.Tests/Tools/ShellApprovalDispositionMatrixTests.Shell_approval_cases_match_review_table.verified.md index 83d8f972e..d5b321aad 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellApprovalDispositionMatrixTests.Shell_approval_cases_match_review_table.verified.md +++ b/src/Netclaw.Actors.Tests/Tools/ShellApprovalDispositionMatrixTests.Shell_approval_cases_match_review_table.verified.md @@ -32,10 +32,10 @@ | mixed-safe-unsafe-compound-prompts | Bash | Personal | Project | Interactive | git status && git push | none | RequiresApproval | approval required | git push | No | | safe-pipe-unsafe-tail-prompts | Bash | Personal | Project | Interactive | git status \| git push | none | RequiresApproval | approval required | git push | No | | safe-pipeline-allows | Bash | Personal | Project | Interactive | git log \| head -20 | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | -| live-read-chain-with-separator-allows | Bash | Personal | Project | Interactive | rg -rn "operation failed" src/ tests/ \| head -20; echo "---"; rg -rln "upload" src/ \| head -20 | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable | -| live-git-diagnostic-chain-with-separators-allows | Bash | Personal | Project | Interactive | git status --short 2>&1 \| head; echo "---branch---"; git branch --show-current 2>&1; echo "---remotes---"; git remote -v 2>&1 \| head -4; echo "---recent---"; git log --oneline -3 2>&1 | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable | +| live-read-chain-with-separator-allows | Bash | Personal | Project | Interactive | rg -rn "operation failed" src/ tests/ \| head -20; echo "---"; rg -rln "upload" src/ \| head -20 | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | +| live-git-diagnostic-chain-with-separators-allows | Bash | Personal | Project | Interactive | git status --short 2>&1 \| head; echo "---branch---"; git branch --show-current 2>&1; echo "---remotes---"; git remote -v 2>&1 \| head -4; echo "---recent---"; git log --oneline -3 2>&1 | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | | live-finite-url-loop-prompts-with-reusable-phrase | Bash | Personal | Project | Interactive | for url in /api/first /api/second; do echo "=== $url ==="; curl -sS -m 10 "$url" \| head -c 1500; echo; done | none | RequiresApproval | approval required | curl | No | -| safe-gh-run-diagnostic-exit-status-allows | Bash | Personal | Project | Interactive | gh run view 123456 --repo example/project --log-failed --verbose 2>&1 \| head -200; echo "---EXIT $?---" | none | Allowed | ApprovalExemptShellCandidates | none | Not applicable | +| safe-gh-run-diagnostic-exit-status-allows | Bash | Personal | Project | Interactive | gh run view 123456 --repo example/project --log-failed --verbose 2>&1 \| head -200; echo "---EXIT $?---" | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | | native-project-path-operand-allows-safe-verb | Bash | Personal | Project | Interactive | git diff install-skills.sh | none | Allowed | SafeVerbInTrustedScope | none | Not applicable | | native-external-path-operand-prompts | Bash | Personal | Project | Interactive | git diff /etc/passwd | none | RequiresApproval | approval required | git diff | No | | native-project-path-operand-reuses-grant | Bash | Personal | Project | Interactive | kubectl apply deployment.yaml | persistent[project]:kubectl apply | Allowed | StoredApproval | none | Not applicable | diff --git a/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs b/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs index 9963a6ac9..ea9e679f1 100644 --- a/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs +++ b/src/Netclaw.Actors.Tests/Tools/ShellApprovalHarness.cs @@ -328,7 +328,8 @@ public Task GetAsync(CancellationToken cancellationToken = default) internal sealed class CountingApprovalService(IToolApprovalService inner) : IToolApprovalService, - IStructuredToolApprovalService + IStructuredToolApprovalService, + IShellApprovalMatchService { private int _checkCount; @@ -346,6 +347,16 @@ public async Task CheckApprovalAsync( return await inner.CheckApprovalAsync(sessionId, audience, toolName, candidates, cwd, ct); } + public async Task MatchShellCandidatesAsync( + ShellApprovalMatchRequest request, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _checkCount); + return await ((IShellApprovalMatchService)inner).MatchShellCandidatesAsync( + request, + cancellationToken); + } + public Task> GetUnapprovedPatternsAsync( ToolApprovalSessionId? sessionId, TrustAudience audience, diff --git a/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs index e06edbdc5..9b3860473 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolApprovalActorTests.cs @@ -728,6 +728,63 @@ [new ToolApprovalGrant(candidate, Directory: null)], } } + [Fact] + public async Task Typed_shell_batch_preserves_ids_and_store_status() + { + var ct = TestContext.Current.CancellationToken; + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, "{\"version\":3,\"audiences\":{\"personal\":null}}"); + var store = new ToolApprovalStore( + tempFile, + timeProvider: null, + migrationContext: new ApprovalStoreMigrationContext(NativeShell), + lockTimeout: TimeSpan.Zero); + var actor = Sys.ActorOf(ToolApprovalActor.CreateProps(store)); + var service = CreateService(actor); + await service.RecordApprovalCandidatesAsync( + (ToolApprovalSessionId)"session-a", + TrustAudience.Personal, + new ToolName("shell_execute"), + [new ToolApprovalGrant(NativeCandidate("git status"), Directory: null)], + persistent: false, + ct); + var candidates = Array.AsReadOnly( + [ + new ShellGrantCandidate( + new ShellPolicyCandidateId(7), + NativeCandidate("git status"), + RealDirectory: null), + new ShellGrantCandidate( + new ShellPolicyCandidateId(11), + NativeCandidate("dotnet test"), + RealDirectory: null) + ]); + + var result = await ((IShellApprovalMatchService)service).MatchShellCandidatesAsync( + new ShellApprovalMatchRequest( + (ToolApprovalSessionId)"session-a", + TrustAudience.Personal, + new ToolName("shell_execute"), + TestShellEnvironment.Current, + candidates), + ct); + + var unavailable = Assert.IsType(result.PersistentStore); + Assert.Equal(ApprovalStoreFailure.InvalidData, unavailable.Failure); + Assert.Equal([7, 11], result.CandidateMatches.Select(match => match.CandidateId.Value)); + Assert.Equal(ShellCoverageKind.Session, result.CandidateMatches[0].GrantCoverage); + Assert.NotNull(result.CandidateMatches[0].Match); + Assert.Null(result.CandidateMatches[1].GrantCoverage); + Assert.Null(result.CandidateMatches[1].Match); + } + finally + { + File.Delete(tempFile); + } + } + private static AkkaToolApprovalService CreateService(IActorRef actor) => new(new StubRequiredActor(actor), TestShellEnvironment.Current); diff --git a/src/Netclaw.Actors/Tools/AkkaToolApprovalService.cs b/src/Netclaw.Actors/Tools/AkkaToolApprovalService.cs index dc68bce80..488cec32f 100644 --- a/src/Netclaw.Actors/Tools/AkkaToolApprovalService.cs +++ b/src/Netclaw.Actors/Tools/AkkaToolApprovalService.cs @@ -14,7 +14,10 @@ namespace Netclaw.Actors.Tools; -public sealed class AkkaToolApprovalService : IToolApprovalService, IStructuredToolApprovalService +public sealed class AkkaToolApprovalService : + IToolApprovalService, + IStructuredToolApprovalService, + IShellApprovalMatchService { private readonly IRequiredActor _actorProvider; private readonly ShellExecutionEnvironment? _compatibilityEnvironment; @@ -81,6 +84,27 @@ public async Task CheckApprovalAsync( return response.Result; } + async Task IShellApprovalMatchService.MatchShellCandidatesAsync( + ShellApprovalMatchRequest request, + CancellationToken cancellationToken) + { + var actor = await _actorProvider.GetAsync(cancellationToken); + var protocolSessionId = request.SessionId.HasValue + ? (SessionId)request.SessionId.Value.Value + : (SessionId?)null; + var response = await actor.Ask( + new MatchShellCandidates( + protocolSessionId, + request.Audience, + request.ToolName, + request.Environment, + request.Candidates), + TimeSpan.FromSeconds(5), + cancellationToken); + + return response.Result; + } + public async Task RecordApprovalAsync( string sessionId, TrustAudience audience, diff --git a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs index 6e316d03b..1b6c5dbdc 100644 --- a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs +++ b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs @@ -24,6 +24,7 @@ public sealed class DispatchingToolExecutor : IToolExecutor, ISessionScratchRetr private readonly ToolRegistry _registry; private readonly ToolAccessPolicy _policy; private readonly IToolApprovalService? _approvalService; + private readonly ShellPolicyCoordinator _shellPolicyCoordinator; private readonly ILogger _logger; public DispatchingToolExecutor(ToolRegistry registry, ToolAccessPolicy policy, @@ -32,6 +33,7 @@ public DispatchingToolExecutor(ToolRegistry registry, ToolAccessPolicy policy, _registry = registry; _policy = policy; _approvalService = approvalService; + _shellPolicyCoordinator = new ShellPolicyCoordinator(policy, approvalService); _logger = logger ?? (ILogger)NullLogger.Instance; } @@ -297,6 +299,17 @@ internal async Task EvaluateAuthorizationAsync( return missingToolDecision; } + if (string.Equals(tool.Name, ShellTool.ToolName, StringComparison.Ordinal)) + { + var shellDecision = await _shellPolicyCoordinator.EvaluateAsync( + tool, + toolCall, + context, + ct); + LogAuthorizationDecision(toolCall.Name, shellDecision); + return shellDecision; + } + var accessDecision = _policy.AuthorizeInvocation(tool, context, toolCall.Arguments); IReadOnlyList approvalMatches = []; @@ -304,119 +317,48 @@ internal async Task EvaluateAuthorizationAsync( { var approvalContext = accessDecision.ApprovalContext ?? throw new InvalidOperationException("Approval decision missing approval context."); + var candidatesForCheck = approvalContext.Candidates is { Count: > 0 } candidates + ? candidates.ToList() + : approvalContext.CandidateVerbs + .Select(verb => new ApprovalCandidate(verb, Directory: null)) + .ToList(); - // Cwd resolution happens upstream in ToolAccessPolicy.CheckApprovalGate - // for shell tools, so the attempt Cwd is already populated when the - // gate produced an approval context. Other tools have no - // directory anchor; cwd stays null. - - // Messy commands cannot be persistently approved — the matcher - // refuses to extract verb chains we could match a future - // invocation against. Always round-trip through the user, even if - // the candidate-verbs list happens to be empty for unrelated - // reasons (which would otherwise short-circuit to allow). - if (approvalContext.IsMessy) - { - accessDecision = ToolAccessDecision.RequiresApproval(approvalContext); - } - else + if (candidatesForCheck.Count > 0) { - var audience = context.Audience; - - // Pure side-effect candidates (echo "X" with no path/redirect, - // bash :, true/false) are not persisted on Always-here clicks - // and must also be treated as authorized at match time — - // otherwise the matcher would see them as unapproved on retry - // after the click, throw ToolApprovalRequiredException again, - // and fail the turn (the outer try/catch is already inside - // the conditional catch so a re-throw escapes). - var candidatesForCheck = approvalContext.Candidates is { Count: > 0 } candidates - ? candidates - .Where(c => !ApprovalPatternMatching.IsPureSideEffect(c)) - .ToList() - : approvalContext.CandidateVerbs - .Select(verb => new ApprovalCandidate(verb, Directory: null)) - .ToList(); - - if (approvalContext.Candidates is { Count: > 0 } - && candidatesForCheck.Count == 0) + var approvalCheck = await _approvalService.CheckApprovalAsync( + ToApprovalSessionId(context.SessionId), + context.Audience, + new ToolName(tool.Name), + candidatesForCheck, + context.Approval.Cwd, + ct); + approvalMatches = approvalCheck.ApprovedMatches; + var hasExactCandidateChecks = TryGetExactUnapprovedCandidates( + approvalCheck, + candidatesForCheck, + out _); + var hasInconsistentCandidateChecks = approvalCheck.CandidateChecks is not null + && !hasExactCandidateChecks; + var storeUnavailableForMiss = approvalCheck.PersistentStoreFailure is not null + && approvalCheck.UnapprovedPatterns.Count > 0; + + if (storeUnavailableForMiss) { - // Every candidate is side-effect-only — auto-allow. - accessDecision = ToolAccessDecision.Allow(ToolAllowReason.ApprovalExemptShellCandidates); + accessDecision = IsOneTimeApprovalSatisfied(context, toolCall, approvalContext) + ? ToolAccessDecision.Allow(ToolAllowReason.OneTimeApproval) + : ToolAccessDecision.Deny("approval_store_unavailable"); } - else if (candidatesForCheck.Count == 0) + else if (approvalCheck.UnapprovedPatterns.Count == 0 + && !hasInconsistentCandidateChecks) { - // A zero-candidate result does not prove that the command is exempt. - // Malformed input or a parser rejection can also produce this result. - accessDecision = ToolAccessDecision.RequiresApproval(approvalContext); + context.Approval.ApplyDecision( + "PreviouslyApproved", + FormatApprovalMatches(approvalCheck.ApprovedMatches)); + accessDecision = ToolAccessDecision.Allow(ToolAllowReason.StoredApproval); } else { - // Use tool.Name (canonical) — not toolCall.Name — so the - // lookup key matches what PersistApprovalCandidatesAsync - // stored. For MCP tools the LLM-facing name is the - // sanitized alias (`server__tool`), while the policy - // builds the approval context — and the session actor - // records the grant — under the canonical `server/tool`. - // Looking up by the sanitized alias here would miss every - // grant and re-throw ToolApprovalRequiredException on - // approved retries. - var approvalCheck = await _approvalService.CheckApprovalAsync( - ToApprovalSessionId(context.SessionId), - audience, - new ToolName(tool.Name), - candidatesForCheck, - context.Approval.Cwd, - ct); - approvalMatches = approvalCheck.ApprovedMatches; - var hasExactCandidateChecks = TryGetExactUnapprovedCandidates( - approvalCheck, - candidatesForCheck, - out var unapprovedCandidates); - var hasInconsistentCandidateChecks = approvalCheck.CandidateChecks is not null - && !hasExactCandidateChecks; - - var storeUnavailableForMiss = approvalCheck.PersistentStoreFailure is not null && - approvalCheck.UnapprovedPatterns.Count > 0; - if (storeUnavailableForMiss) - { - accessDecision = IsOneTimeApprovalSatisfied(context, toolCall, approvalContext) - ? ToolAccessDecision.Allow(ToolAllowReason.OneTimeApproval) - : ToolAccessDecision.Deny("approval_store_unavailable"); - } - else - { - if (approvalCheck.UnapprovedPatterns.Count == 0 - && !hasInconsistentCandidateChecks) - { - context.Approval.ApplyDecision( - "PreviouslyApproved", - FormatApprovalMatches(approvalCheck.ApprovedMatches)); - } - - if (accessDecision.Allowed - && approvalCheck.UnapprovedPatterns.Count == 0 - && !hasInconsistentCandidateChecks) - { - accessDecision = ToolAccessDecision.Allow(ToolAllowReason.StoredApproval); - } - else if (accessDecision.Allowed) - { - // New approval services return exact candidate occurrences. - // An older implementation can only return verb strings, so - // keep the broader context instead of guessing which scoped - // candidate lacks approval. - var promptContext = hasExactCandidateChecks - && unapprovedCandidates.Count > 0 - && string.Equals(tool.Name, ShellTool.ToolName, StringComparison.Ordinal) - ? ToolAccessPolicy.NarrowShellApprovalContext( - approvalContext, - unapprovedCandidates, - context.SessionDirectory) - : approvalContext; - accessDecision = ToolAccessDecision.RequiresApproval(promptContext); - } - } + accessDecision = ToolAccessDecision.RequiresApproval(approvalContext); } } } diff --git a/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs b/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs new file mode 100644 index 000000000..c8595d911 --- /dev/null +++ b/src/Netclaw.Actors/Tools/ShellPolicyCoordinator.cs @@ -0,0 +1,514 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.AI; +using Netclaw.Configuration; +using Netclaw.Security; +using Netclaw.Tools; + +namespace Netclaw.Actors.Tools; + +/// +/// Coordinates shell preflight, one approval-store check, and final policy. +/// +internal sealed class ShellPolicyCoordinator( + ToolAccessPolicy policy, + IToolApprovalService? approvalService) +{ + internal async Task EvaluateAsync( + INetclawTool tool, + FunctionCallContent toolCall, + ToolExecutionContext context, + CancellationToken cancellationToken) + { + try + { + return await EvaluateCoreAsync(tool, toolCall, context, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception) + { + return ToolAuthorizationDecision.Deny("internal_policy_failure"); + } + } + + private async Task EvaluateCoreAsync( + INetclawTool tool, + FunctionCallContent toolCall, + ToolExecutionContext context, + CancellationToken cancellationToken) + { + var preflight = policy.AuthorizeShellPreflight(tool, context, toolCall.Arguments); + if (!preflight.NeedsApproval) + return Complete(preflight, []); + + var approvalContext = preflight.ApprovalContext; + policy.TryGetAuthorizedShellAnalysis(context, out var execution); + if (approvalContext is null + || !ShellPolicyProjection.TryCreate( + policy.ShellEnvironment, + execution, + approvalContext, + context, + out var projection) + || projection is null) + { + return ToolAuthorizationDecision.Deny("internal_policy_failure"); + } + + return await CompleteAsync( + tool, + toolCall, + context, + projection, + cancellationToken); + } + + private async Task CompleteAsync( + INetclawTool tool, + FunctionCallContent toolCall, + ToolExecutionContext context, + ShellPolicyProjection projection, + CancellationToken cancellationToken) + { + if (projection.ApprovalContext.IsMessy) + return CompleteOneTimeOrPrompt(toolCall.Name, projection, projection.ApprovalContext, []); + + if (projection.Candidates.Count == 0) + return CompleteOneTimeOrPrompt(toolCall.Name, projection, projection.ApprovalContext, []); + + var expectedShell = projection.Environment.Grammar == ShellGrammar.Bash + ? ApprovalShell.Bash + : ApprovalShell.PowerShell; + if (projection.Candidates.Any(candidate => candidate.Candidate.Shell is null + || candidate.Candidate.VerbTokens is null)) + { + return CompleteOneTimeOrPrompt(toolCall.Name, projection, projection.ApprovalContext, []); + } + + 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 ToolAuthorizationDecision.Deny("internal_policy_failure"); + } + + var coverage = new ShellCoverageSet(projection.Candidates); + foreach (var candidate in projection.Candidates.Where(item => + approvalService is not null + && ApprovalPatternMatching.IsPureSideEffect(item.Candidate))) + { + coverage.Cover( + candidate.Id, + ShellCoverageKind.ReviewedSafePolicy, + ShellPolicyReason.ApprovalExemptSideEffect); + } + + var grantCandidates = projection.GrantCandidates; + var actorResult = await MatchCandidatesAsync( + tool, + context, + projection, + grantCandidates, + cancellationToken); + if (!TryApplyActorResult( + actorResult, + grantCandidates, + projection.ApprovalContext.Cwd, + coverage, + out var approvalMatches)) + return ToolAuthorizationDecision.Deny("internal_policy_failure"); + + foreach (var candidate in grantCandidates.Where(candidate => + coverage.UncoveredIds.Contains(candidate.Id))) + { + if (policy.IsReviewedSafeCandidate( + candidate.Candidate, + projection.ApprovalContext.Cwd, + context.Invocation)) + { + coverage.Cover( + candidate.Id, + ShellCoverageKind.ReviewedSafePolicy, + ShellPolicyReason.ReviewedSafePhrase); + } + } + + var uncovered = GetUncoveredCandidates(projection, coverage); + var oneTimeApplied = false; + if (uncovered.Count > 0) + { + var remainingContext = ToolAccessPolicy.NarrowShellApprovalContext( + projection.ApprovalContext, + uncovered.Select(static candidate => candidate.Candidate).ToArray(), + context.SessionDirectory); + if (projection.HasExactOneTimeApproval(toolCall.Name, remainingContext)) + { + foreach (var candidate in uncovered) + { + coverage.Cover( + candidate.Id, + ShellCoverageKind.OneTime, + ShellPolicyReason.OneTimeGrant); + } + + uncovered = []; + oneTimeApplied = true; + } + } + + if (uncovered.Count > 0 + && actorResult.PersistentStore is PersistentGrantStoreStatus.Unavailable) + { + return ToolAuthorizationDecision.Deny("approval_store_unavailable"); + } + + if (uncovered.Count > 0) + { + var promptContext = ToolAccessPolicy.NarrowShellApprovalContext( + projection.ApprovalContext, + uncovered.Select(static candidate => candidate.Candidate).ToArray(), + context.SessionDirectory); + return ToolAuthorizationDecision.RequiresApproval(promptContext, approvalMatches); + } + + if (!coverage.AllCovered) + return ToolAuthorizationDecision.Deny("internal_policy_failure"); + + if (oneTimeApplied) + { + return ToolAuthorizationDecision.Allow( + ToolAllowReason.OneTimeApproval, + approvalMatches); + } + + if (approvalMatches.Count > 0) + { + if (approvalMatches.Count == grantCandidates.Count) + { + context.Approval.ApplyDecision( + "PreviouslyApproved", + FormatApprovalMatches(approvalMatches)); + } + + return ToolAuthorizationDecision.Allow( + ToolAllowReason.StoredApproval, + approvalMatches); + } + + return ToolAuthorizationDecision.Allow( + grantCandidates.Count == 0 + ? ToolAllowReason.ApprovalExemptShellCandidates + : ToolAllowReason.SafeVerbInTrustedScope); + } + + private async Task MatchCandidatesAsync( + INetclawTool tool, + ToolExecutionContext context, + ShellPolicyProjection projection, + IReadOnlyList candidates, + CancellationToken cancellationToken) + { + if (candidates.Count == 0 || approvalService is null) + { + return CreateEmptyMatchResult(candidates); + } + + var requestCandidates = candidates + .Select(candidate => new ShellGrantCandidate( + candidate.Id, + candidate.Candidate, + projection.ApprovalContext.Cwd)) + .ToArray(); + if (approvalService is IShellApprovalMatchService shellApprovalService) + { + return await shellApprovalService.MatchShellCandidatesAsync( + new ShellApprovalMatchRequest( + ToApprovalSessionId(context.SessionId), + context.Audience, + new ToolName(tool.Name), + projection.Environment, + Array.AsReadOnly(requestCandidates)), + cancellationToken); + } + + var compatibilityResult = await approvalService.CheckApprovalAsync( + ToApprovalSessionId(context.SessionId), + context.Audience, + new ToolName(tool.Name), + candidates.Select(static candidate => candidate.Candidate).ToArray(), + projection.ApprovalContext.Cwd, + cancellationToken); + return ConvertCompatibilityResult(compatibilityResult, candidates); + } + + private static ShellApprovalMatchResult ConvertCompatibilityResult( + ToolApprovalCheckResult result, + IReadOnlyList candidates) + { + if (result.CandidateChecks is not { } checks) + { + var aggregateStoreStatus = result.PersistentStoreFailure is { } aggregateFailure + ? (PersistentGrantStoreStatus)new PersistentGrantStoreStatus.Unavailable(aggregateFailure) + : new PersistentGrantStoreStatus.Ready(); + return new ShellApprovalMatchResult( + aggregateStoreStatus, + Array.AsReadOnly(candidates + .Select(static candidate => new ShellGrantCandidateMatch( + candidate.Id, + Match: null, + GrantCoverage: null, + NearMisses: [])) + .ToArray())); + } + + if (checks.Count != candidates.Count) + throw new InvalidOperationException("The approval service returned the wrong candidate count."); + + var matches = new ShellGrantCandidateMatch[checks.Count]; + var unapprovedPatterns = new List(); + var approvedMatches = new List(); + for (var index = 0; index < checks.Count; index++) + { + var expected = candidates[index]; + var check = checks[index]; + if (!HasSameCandidateFacts(check.Candidate, expected.Candidate)) + throw new InvalidOperationException("The approval service changed candidate facts."); + + ShellCoverageKind? grantCoverage = null; + if (check.ApprovedMatch is { } approvedMatch) + { + approvedMatches.Add(approvedMatch); + grantCoverage = approvedMatch.Source switch + { + "session" => ShellCoverageKind.Session, + "persistent" when approvedMatch.Scope.EndsWith(" anywhere", StringComparison.Ordinal) => + ShellCoverageKind.PersistentGlobal, + "persistent" => ShellCoverageKind.PersistentFolder, + _ => throw new InvalidOperationException("The approval service returned an unknown grant source."), + }; + } + else + { + unapprovedPatterns.Add(expected.Candidate.Verb); + } + + matches[index] = new ShellGrantCandidateMatch( + expected.Id, + check.ApprovedMatch, + grantCoverage, + []); + } + + if (!unapprovedPatterns.SequenceEqual( + result.UnapprovedPatterns, + StringComparer.OrdinalIgnoreCase) + || !approvedMatches.SequenceEqual(result.ApprovedMatches)) + { + throw new InvalidOperationException("The approval service returned inconsistent aggregates."); + } + + var storeStatus = result.PersistentStoreFailure is { } failure + ? (PersistentGrantStoreStatus)new PersistentGrantStoreStatus.Unavailable(failure) + : new PersistentGrantStoreStatus.Ready(); + return new ShellApprovalMatchResult( + storeStatus, + Array.AsReadOnly(matches)); + } + + private static bool TryApplyActorResult( + ShellApprovalMatchResult result, + IReadOnlyList candidates, + string? cwd, + ShellCoverageSet coverage, + out IReadOnlyList approvalMatches) + { + approvalMatches = []; + if (result.PersistentStore is PersistentGrantStoreStatus.Unavailable unavailable + && !Enum.IsDefined(unavailable.Failure)) + { + return false; + } + + if (result.PersistentStore is not PersistentGrantStoreStatus.Ready + && result.PersistentStore is not PersistentGrantStoreStatus.Unavailable) + { + return false; + } + + if (result.CandidateMatches.Count != candidates.Count) + return false; + + var expectedIds = candidates.Select(static candidate => candidate.Id).ToHashSet(); + var seenIds = new HashSet(); + var matches = new List(); + foreach (var candidateMatch in result.CandidateMatches) + { + if (!expectedIds.Contains(candidateMatch.CandidateId) + || !seenIds.Add(candidateMatch.CandidateId)) + { + return false; + } + + if (candidateMatch.Match is null) + { + if (candidateMatch.GrantCoverage is not null) + return false; + + continue; + } + + if (candidateMatch.GrantCoverage is not + (ShellCoverageKind.Session + or ShellCoverageKind.PersistentGlobal + or ShellCoverageKind.PersistentFolder)) + { + return false; + } + + var candidate = candidates.First(item => item.Id == candidateMatch.CandidateId); + if (!IsConsistentActorMatch( + candidate.Candidate, + candidateMatch.Match, + candidateMatch.GrantCoverage.Value, + cwd)) + { + return false; + } + + if (result.PersistentStore is PersistentGrantStoreStatus.Unavailable + && candidateMatch.GrantCoverage is + (ShellCoverageKind.PersistentGlobal or ShellCoverageKind.PersistentFolder)) + { + return false; + } + + coverage.Cover( + candidateMatch.CandidateId, + candidateMatch.GrantCoverage.Value, + ToPolicyReason(candidateMatch.GrantCoverage.Value)); + matches.Add(candidateMatch.Match); + } + + approvalMatches = Array.AsReadOnly(matches.ToArray()); + return true; + } + + private static bool IsConsistentActorMatch( + ApprovalCandidate candidate, + ToolApprovalMatch match, + ShellCoverageKind coverage, + string? cwd) + { + if (!string.Equals(match.Pattern, candidate.Verb, StringComparison.Ordinal)) + return false; + + if (coverage == ShellCoverageKind.Session) + { + return string.Equals(match.Source, "session", StringComparison.Ordinal) + && string.Equals(match.Scope, "this chat", StringComparison.Ordinal); + } + + if (coverage is not + (ShellCoverageKind.PersistentGlobal or ShellCoverageKind.PersistentFolder) + || !string.Equals(match.Source, "persistent", StringComparison.Ordinal) + || !ApprovalEntry.TryParseScope(match.Scope, out var entry, out _) + || entry.Shell != candidate.Shell + || entry.Match is null + || (coverage == ShellCoverageKind.PersistentGlobal) != (entry.Directory is null)) + { + return false; + } + + return ApprovalPatternMatching.MatchesShellApproval(candidate, cwd, [entry]); + } + + private static IReadOnlyList GetUncoveredCandidates( + ShellPolicyProjection projection, + ShellCoverageSet coverage) + { + var uncoveredIds = coverage.UncoveredIds.ToHashSet(); + return projection.Candidates + .Where(candidate => uncoveredIds.Contains(candidate.Id)) + .ToArray(); + } + + private static ShellPolicyReason ToPolicyReason(ShellCoverageKind kind) => kind switch + { + ShellCoverageKind.Session => ShellPolicyReason.SessionGrant, + ShellCoverageKind.PersistentGlobal => ShellPolicyReason.PersistentGlobalGrant, + ShellCoverageKind.PersistentFolder => ShellPolicyReason.PersistentFolderGrant, + _ => throw new InvalidOperationException("Invalid actor coverage kind."), + }; + + private static ShellApprovalMatchResult CreateEmptyMatchResult( + IReadOnlyList candidates) + => new( + new PersistentGrantStoreStatus.Ready(), + Array.AsReadOnly(candidates + .Select(static candidate => new ShellGrantCandidateMatch( + candidate.Id, + Match: null, + GrantCoverage: null, + NearMisses: [])) + .ToArray())); + + private static ToolAuthorizationDecision CompleteOneTimeOrPrompt( + string toolName, + ShellPolicyProjection projection, + ToolApprovalContext approvalContext, + IReadOnlyList approvalMatches) + => projection.HasExactOneTimeApproval(toolName, approvalContext) + ? ToolAuthorizationDecision.Allow(ToolAllowReason.OneTimeApproval, approvalMatches) + : ToolAuthorizationDecision.RequiresApproval(approvalContext, approvalMatches); + + private static bool HasSameCandidateFacts( + ApprovalCandidate first, + ApprovalCandidate second) => + string.Equals(first.Verb, second.Verb, StringComparison.Ordinal) && + string.Equals(first.Directory, second.Directory, StringComparison.Ordinal) && + first.Shell == second.Shell && + ((first.VerbTokens is null && second.VerbTokens is null) || + (first.VerbTokens is not null && + second.VerbTokens is not null && + first.VerbTokens.SequenceEqual(second.VerbTokens, StringComparer.Ordinal))); + + private static ToolAuthorizationDecision Complete( + ToolAccessDecision decision, + IReadOnlyList approvalMatches) + { + if (decision.NeedsApproval) + { + return ToolAuthorizationDecision.RequiresApproval( + decision.ApprovalContext + ?? throw new InvalidOperationException("Approval decision missing approval context."), + approvalMatches); + } + + if (!decision.Allowed) + { + return ToolAuthorizationDecision.Deny( + decision.DenyReason + ?? throw new InvalidOperationException("Denied decision missing a deny reason.")); + } + + return ToolAuthorizationDecision.Allow( + decision.AllowReason + ?? throw new InvalidOperationException("Allowed decision missing an allow reason."), + approvalMatches); + } + + private static string FormatApprovalMatches(IReadOnlyList matches) + => string.Join(", ", matches.Select(match => + $"{match.Pattern} [{match.Source}: {match.Scope}]")); + + private static ToolApprovalSessionId? ToApprovalSessionId(string? sessionId) + => sessionId is null ? null : (ToolApprovalSessionId)sessionId; +} diff --git a/src/Netclaw.Actors/Tools/ShellPolicyProjection.cs b/src/Netclaw.Actors/Tools/ShellPolicyProjection.cs new file mode 100644 index 000000000..58a1fc5e4 --- /dev/null +++ b/src/Netclaw.Actors/Tools/ShellPolicyProjection.cs @@ -0,0 +1,214 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Collections.Frozen; +using Netclaw.Configuration; +using Netclaw.Security; +using Netclaw.Tools; + +namespace Netclaw.Actors.Tools; + +internal enum ShellCoverageKind +{ + Uncovered = 0, + OneTime = 1, + Session = 2, + PersistentGlobal = 3, + PersistentFolder = 4, + ReviewedSafePolicy = 5, + Denied = 6, +} + +internal enum ShellPolicyReason +{ + None = 0, + OneTimeGrant = 1, + SessionGrant = 2, + PersistentGlobalGrant = 3, + PersistentFolderGrant = 4, + ReviewedSafePhrase = 5, + ApprovalExemptSideEffect = 6, +} + +internal readonly record struct ShellPolicyCandidateId +{ + internal ShellPolicyCandidateId(int value) + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + Value = value; + } + + internal int Value { get; } +} + +internal sealed record ShellPolicyCandidate( + ShellPolicyCandidateId Id, + ApprovalCandidate Candidate); + +internal sealed record ShellCandidateCoverage( + ShellPolicyCandidateId CandidateId, + ShellCoverageKind Kind, + ShellPolicyReason Reason); + +/// +/// The immutable policy-facing projection of one shell approval context. +/// +internal sealed record ShellPolicyProjection +{ + private ShellPolicyProjection( + ShellExecutionEnvironment environment, + ShellCommandAnalysis? execution, + ToolRunScope runScope, + ToolApprovalContext approvalContext, + IReadOnlyList candidates, + IReadOnlySet approvedOneTimeKeys, + string? approvedOneTimeToolName) + { + Environment = environment; + Execution = execution; + RunScope = runScope; + ApprovalContext = approvalContext; + Candidates = candidates; + ApprovedOneTimeKeys = approvedOneTimeKeys; + ApprovedOneTimeToolName = approvedOneTimeToolName; + } + + internal ShellExecutionEnvironment Environment { get; } + + internal ShellCommandAnalysis? Execution { get; } + + internal ToolRunScope RunScope { get; } + + internal ToolApprovalContext ApprovalContext { get; } + + internal IReadOnlyList Candidates { get; } + + internal IReadOnlySet ApprovedOneTimeKeys { get; } + + internal string? ApprovedOneTimeToolName { get; } + + internal IReadOnlyList GrantCandidates => + Candidates + .Where(static candidate => + !ApprovalPatternMatching.IsPureSideEffect(candidate.Candidate)) + .ToArray(); + + internal bool HasExactOneTimeApproval( + string toolName, + ToolApprovalContext approvalContext) + { + if (string.IsNullOrEmpty(ApprovedOneTimeToolName) + || !string.Equals(ApprovedOneTimeToolName, toolName, StringComparison.Ordinal)) + { + return false; + } + + return ApprovedOneTimeKeys.SetEquals(OneTimeApprovalKeys.Create(approvalContext)); + } + + internal static bool TryCreate( + ShellExecutionEnvironment environment, + ShellCommandAnalysis? execution, + ToolApprovalContext approvalContext, + ToolExecutionContext context, + out ShellPolicyProjection? projection) + { + ArgumentNullException.ThrowIfNull(environment); + ArgumentNullException.ThrowIfNull(approvalContext); + ArgumentNullException.ThrowIfNull(context); + + projection = null; + if (approvalContext.Candidates is null) + return false; + + var candidates = new ShellPolicyCandidate[approvalContext.Candidates.Count]; + var candidateCopies = new ApprovalCandidate[approvalContext.Candidates.Count]; + for (var index = 0; index < approvalContext.Candidates.Count; index++) + { + var source = approvalContext.Candidates[index]; + if (source is null) + return false; + + var copy = source with + { + VerbTokens = source.VerbTokens is null + ? null + : Array.AsReadOnly(source.VerbTokens.ToArray()) + }; + candidateCopies[index] = copy; + candidates[index] = new ShellPolicyCandidate( + new ShellPolicyCandidateId(index), + copy); + } + + var contextCopy = approvalContext with + { + Patterns = Array.AsReadOnly(approvalContext.Patterns.ToArray()), + CandidateVerbs = Array.AsReadOnly(approvalContext.CandidateVerbs.ToArray()), + Options = Array.AsReadOnly(approvalContext.Options.ToArray()), + Candidates = Array.AsReadOnly(candidateCopies) + }; + var runScopeCopy = context.RunScope with + { + RecentFiles = Array.AsReadOnly(context.RunScope.RecentFiles.ToArray()) + }; + projection = new ShellPolicyProjection( + environment, + execution, + runScopeCopy, + contextCopy, + Array.AsReadOnly(candidates), + context.Approval.OneTimeApprovedPatterns.ToFrozenSet(StringComparer.OrdinalIgnoreCase), + context.Approval.OneTimeApprovedToolName); + return true; + } +} + +internal sealed class ShellCoverageSet +{ + private readonly Dictionary _coverage; + + internal ShellCoverageSet(IReadOnlyList candidates) + { + _coverage = new Dictionary(candidates.Count); + foreach (var candidate in candidates) + { + if (!_coverage.TryAdd( + candidate.Id, + new ShellCandidateCoverage( + candidate.Id, + ShellCoverageKind.Uncovered, + ShellPolicyReason.None))) + { + throw new InvalidOperationException("Duplicate shell candidate id."); + } + } + } + + internal IReadOnlyList UncoveredIds => _coverage.Values + .Where(static item => item.Kind == ShellCoverageKind.Uncovered) + .Select(static item => item.CandidateId) + .ToArray(); + + internal bool AllCovered => _coverage.Values.All(static item => + item.Kind is not ShellCoverageKind.Uncovered and not ShellCoverageKind.Denied); + + internal void Cover( + ShellPolicyCandidateId candidateId, + ShellCoverageKind kind, + ShellPolicyReason reason) + { + if (kind is ShellCoverageKind.Uncovered or ShellCoverageKind.Denied) + throw new InvalidOperationException("Invalid shell coverage transition."); + + if (!_coverage.TryGetValue(candidateId, out var current) + || current.Kind != ShellCoverageKind.Uncovered) + { + throw new InvalidOperationException("Shell candidate coverage can be assigned once."); + } + + _coverage[candidateId] = new ShellCandidateCoverage(candidateId, kind, reason); + } +} diff --git a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs index d356266d4..e19245292 100644 --- a/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs +++ b/src/Netclaw.Actors/Tools/ToolAccessPolicy.cs @@ -43,6 +43,8 @@ private readonly ConditionalWeakTable _shellCommandPolicy.Environment; + public ToolAccessPolicy( ToolConfig toolConfig, EffectivePolicyDefaults defaults, @@ -156,6 +158,19 @@ public ToolAccessDecision AuthorizeInvocation( INetclawTool tool, ToolExecutionContext context, IDictionary? arguments) + => AuthorizeInvocationCore(tool, context, arguments, deferReviewedSafeCoverage: false); + + internal ToolAccessDecision AuthorizeShellPreflight( + INetclawTool tool, + ToolExecutionContext context, + IDictionary? arguments) + => AuthorizeInvocationCore(tool, context, arguments, deferReviewedSafeCoverage: true); + + private ToolAccessDecision AuthorizeInvocationCore( + INetclawTool tool, + ToolExecutionContext context, + IDictionary? arguments, + bool deferReviewedSafeCoverage) { _authorizedShellAnalyses.Remove(context); @@ -256,7 +271,8 @@ public ToolAccessDecision AuthorizeInvocation( arguments, _shellApprovalMatcher, shellApproval, - shellAnalysis); + shellAnalysis, + deferReviewedSafeCoverage); } internal bool TryTakeAuthorizedShellAnalysis( @@ -270,6 +286,11 @@ internal bool TryTakeAuthorizedShellAnalysis( return true; } + internal bool TryGetAuthorizedShellAnalysis( + ToolExecutionContext context, + out ShellCommandAnalysis? analysis) + => _authorizedShellAnalyses.TryGetValue(context, out analysis); + internal void MarkSessionScratchRetry( ToolExecutionContext context, ToolAgentCorrection.SessionScratchSuggested correction) @@ -278,6 +299,13 @@ internal void MarkSessionScratchRetry( _sessionScratchRetries.Add(context, new SessionScratchRetryMarker(correction)); } + internal bool IsReviewedSafeCandidate( + ApprovalCandidate candidate, + string? cwd, + ToolInvocationContext context) + => _safeVerbPolicy is not null + && _safeVerbPolicy.AllShortCircuit([candidate], cwd, context); + /// /// For non-interactive channels, validates that the working directory and all /// path-like arguments in a shell command are write-authorized for the channel's @@ -376,7 +404,8 @@ private ToolAccessDecision CheckApprovalGate( IDictionary? arguments, IToolApprovalMatcher matcher, ShellApprovalAnalysis? shellApproval = null, - ShellCommandAnalysis? shellAnalysis = null) + ShellCommandAnalysis? shellAnalysis = null, + bool deferReviewedSafeCoverage = false) { var audience = ResolveAudience(context.Invocation); var profile = ToolAudienceProfileDefaults.GetResolvedProfile(_toolConfig.AudienceProfiles, audience); @@ -470,15 +499,18 @@ private ToolAccessDecision CheckApprovalGate( suggestedProjectDirectory = context.Approval.Cwd; } - approvalCandidates = approvalCandidates - .Where(candidate => !_safeVerbPolicy.AllShortCircuit( - [candidate], - context.Approval.Cwd, - context.Invocation)) - .ToList(); - - if (approvalCandidates.Count == 0) - return ToolAccessDecision.Allow(ToolAllowReason.SafeVerbInTrustedScope); + if (!deferReviewedSafeCoverage) + { + approvalCandidates = approvalCandidates + .Where(candidate => !_safeVerbPolicy.AllShortCircuit( + [candidate], + context.Approval.Cwd, + context.Invocation)) + .ToList(); + + if (approvalCandidates.Count == 0) + return ToolAccessDecision.Allow(ToolAllowReason.SafeVerbInTrustedScope); + } } var candidateVerbs = approvalCandidates diff --git a/src/Netclaw.Actors/Tools/ToolApprovalActor.cs b/src/Netclaw.Actors/Tools/ToolApprovalActor.cs index 688b339ba..d634176e4 100644 --- a/src/Netclaw.Actors/Tools/ToolApprovalActor.cs +++ b/src/Netclaw.Actors/Tools/ToolApprovalActor.cs @@ -29,40 +29,25 @@ public ToolApprovalActor(ToolApprovalStore? persistentStore = null) Receive(msg => { - // Snapshot the persisted approvals once per message — every - // pattern in the same call evaluates against the same on-disk - // state, and Load() does a synchronous file read + JSON parse - // each call. For a compound shell with N candidate verbs this - // collapses N reads into 1. - IReadOnlyList approved = []; - ApprovalStoreFailure? storeFailure = null; - if (_persistentStore is not null) - { - var load = _persistentStore.TryLoad(); - ReportMigrationOmissions(); - if (load is ApprovalStoreLoadResult.Ready ready && - ready.Data.Audiences.TryGetValue(msg.Audience.ToWireValue(), out var tools) && - tools.TryGetValue(msg.ToolName.Value, out var entries)) - { - approved = entries; - } - else if (load is ApprovalStoreLoadResult.Unavailable unavailable) - { - storeFailure = unavailable.Failure; - } - } + var snapshot = LoadPersistentSnapshot(msg.Audience, msg.ToolName); var unapproved = new List(msg.Candidates.Count); var candidateChecks = new List(msg.Candidates.Count); var approvedMatches = new List(msg.Candidates.Count); foreach (var candidate in msg.Candidates) { - var match = MatchApproval(msg.SessionId, msg.Audience, msg.ToolName, candidate, msg.Cwd, approved); + var match = MatchApproval( + msg.SessionId, + msg.Audience, + msg.ToolName, + candidate, + msg.Cwd, + snapshot.Approvals); candidateChecks.Add(new ToolApprovalCandidateCheck(candidate, match)); if (match is null) { unapproved.Add(candidate.Verb); - LogApprovalNearMisses(msg.ToolName, candidate, msg.Cwd, approved); + LogApprovalNearMisses(msg.ToolName, candidate, msg.Cwd, snapshot.Approvals); continue; } @@ -73,10 +58,46 @@ public ToolApprovalActor(ToolApprovalStore? persistentStore = null) new ToolApprovalCheckResult(unapproved, approvedMatches) { CandidateChecks = candidateChecks, - PersistentStoreFailure = storeFailure + PersistentStoreFailure = snapshot.Failure })); }); + Receive(msg => + { + var snapshot = LoadPersistentSnapshot(msg.Audience, msg.ToolName); + var candidateMatches = new List(msg.Candidates.Count); + foreach (var candidate in msg.Candidates) + { + var grantMatch = MatchShellApproval( + msg.SessionId, + msg.Audience, + msg.ToolName, + candidate.Candidate, + candidate.RealDirectory, + snapshot.Approvals); + var nearMisses = grantMatch is null + ? ApprovalPatternMatching.ExplainShellNearMisses( + candidate.Candidate.Verb, + candidate.Candidate.Directory, + candidate.RealDirectory, + snapshot.Approvals) + : []; + candidateMatches.Add(new ShellGrantCandidateMatch( + candidate.CandidateId, + grantMatch?.Match, + grantMatch?.Coverage, + nearMisses)); + } + + var storeStatus = snapshot.Failure is { } failure + ? (PersistentGrantStoreStatus)new PersistentGrantStoreStatus.Unavailable(failure) + : new PersistentGrantStoreStatus.Ready(); + Sender.Tell(new ShellApprovalMatchResponse( + new ShellApprovalMatchResult( + storeStatus, + Array.AsReadOnly(candidateMatches.ToArray())))); + }); + Receive(msg => { if (string.Equals(msg.ToolName.Value, ShellTool.ToolName, StringComparison.Ordinal)) @@ -186,6 +207,27 @@ private void ReportMigrationOmissions() public static Props CreateProps(ToolApprovalStore? persistentStore = null) => Props.Create(() => new ToolApprovalActor(persistentStore)); + private PersistentApprovalSnapshot LoadPersistentSnapshot( + TrustAudience audience, + ToolName toolName) + { + if (_persistentStore is null) + return new PersistentApprovalSnapshot([], Failure: null); + + var load = _persistentStore.TryLoad(); + ReportMigrationOmissions(); + if (load is ApprovalStoreLoadResult.Ready ready + && ready.Data.Audiences.TryGetValue(audience.ToWireValue(), out var tools) + && tools.TryGetValue(toolName.Value, out var entries)) + { + return new PersistentApprovalSnapshot(entries, Failure: null); + } + + return load is ApprovalStoreLoadResult.Unavailable unavailable + ? new PersistentApprovalSnapshot([], unavailable.Failure) + : new PersistentApprovalSnapshot([], Failure: null); + } + private ToolApprovalMatch? MatchApproval(SessionId? sessionId, TrustAudience audience, ToolName toolName, ApprovalCandidate candidate, string? cwd, IReadOnlyList persistedApprovals) { if (sessionId.HasValue && @@ -195,6 +237,37 @@ public static Props CreateProps(ToolApprovalStore? persistentStore = null) return MatchPersistedEntry(toolName, candidate, cwd, persistedApprovals); } + private ShellActorGrantMatch? MatchShellApproval( + SessionId? sessionId, + TrustAudience audience, + ToolName toolName, + ApprovalCandidate candidate, + string? cwd, + IReadOnlyList persistedApprovals) + { + if (sessionId.HasValue + && IsSessionApproved(sessionId.Value, audience, toolName, candidate)) + { + return new ShellActorGrantMatch( + new ToolApprovalMatch(candidate.Verb, "session", "this chat"), + ShellCoverageKind.Session); + } + + foreach (var entry in persistedApprovals) + { + if (!ApprovalPatternMatching.MatchesShellApproval(candidate, cwd, [entry])) + continue; + + return new ShellActorGrantMatch( + new ToolApprovalMatch(candidate.Verb, "persistent", entry.FormatScope()), + entry.Directory is null + ? ShellCoverageKind.PersistentGlobal + : ShellCoverageKind.PersistentFolder); + } + + return null; + } + private bool IsSessionApproved( SessionId sessionId, TrustAudience audience, @@ -385,6 +458,14 @@ private void LogApprovalNearMisses(ToolName toolName, ApprovalCandidate candidat private static string BuildSessionKey(SessionId sessionId, TrustAudience audience) => $"{sessionId.Value}|{audience.ToWireValue()}"; + + private sealed record PersistentApprovalSnapshot( + IReadOnlyList Approvals, + ApprovalStoreFailure? Failure); + + private sealed record ShellActorGrantMatch( + ToolApprovalMatch Match, + ShellCoverageKind Coverage); } internal sealed record ToolApprovalRecorded(ApprovalStoreFailure? Failure) diff --git a/src/Netclaw.Actors/Tools/ToolApprovalMessages.cs b/src/Netclaw.Actors/Tools/ToolApprovalMessages.cs index 12da7f5e3..fa3a234d9 100644 --- a/src/Netclaw.Actors/Tools/ToolApprovalMessages.cs +++ b/src/Netclaw.Actors/Tools/ToolApprovalMessages.cs @@ -34,10 +34,20 @@ internal sealed record GetUnapprovedPatterns( IReadOnlyList Candidates, string? Cwd) : IToolApprovalQuery; + internal sealed record MatchShellCandidates( + SessionId? SessionId, + TrustAudience Audience, + ToolName ToolName, + ShellExecutionEnvironment Environment, + IReadOnlyList Candidates) : IToolApprovalQuery; + // ===== Responses ===== internal sealed record UnapprovedPatternsResponse(ToolApprovalCheckResult Result) : IToolApprovalResponse; + internal sealed record ShellApprovalMatchResponse( + ShellApprovalMatchResult Result) : IToolApprovalResponse; + // ===== Commands ===== internal sealed record RecordToolApproval( @@ -55,3 +65,43 @@ internal sealed record RecordStructuredToolApproval( IReadOnlyList Grants, bool Persistent) : IToolApprovalCommand; } + +internal interface IShellApprovalMatchService +{ + Task MatchShellCandidatesAsync( + ShellApprovalMatchRequest request, + CancellationToken cancellationToken); +} + +internal sealed record ShellApprovalMatchRequest( + ToolApprovalSessionId? SessionId, + TrustAudience Audience, + ToolName ToolName, + ShellExecutionEnvironment Environment, + IReadOnlyList Candidates); + +internal sealed record ShellGrantCandidate( + ShellPolicyCandidateId CandidateId, + ApprovalCandidate Candidate, + string? RealDirectory); + +internal sealed record ShellApprovalMatchResult( + PersistentGrantStoreStatus PersistentStore, + IReadOnlyList CandidateMatches); + +internal abstract record PersistentGrantStoreStatus +{ + private PersistentGrantStoreStatus() + { + } + + internal sealed record Ready : PersistentGrantStoreStatus; + + internal sealed record Unavailable(ApprovalStoreFailure Failure) : PersistentGrantStoreStatus; +} + +internal sealed record ShellGrantCandidateMatch( + ShellPolicyCandidateId CandidateId, + ToolApprovalMatch? Match, + ShellCoverageKind? GrantCoverage, + IReadOnlyList NearMisses);