diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2EvaluationOutcome.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2EvaluationOutcome.cs new file mode 100644 index 0000000..bf4565d --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2EvaluationOutcome.cs @@ -0,0 +1,63 @@ +namespace OpenClaw.Shared.ExecApprovals; + +/// +/// Discriminated outcome from . +/// The coordinator consumes this and either proceeds, rejects, or shows a prompt. +/// +public sealed class ExecApprovalV2EvaluationOutcome +{ + public ExecApprovalV2EvaluationKind Kind { get; } + + /// Non-null when is . + public ExecApprovalV2Result? Denial { get; } + + /// Non-null when is . + public ExecApprovalV2PromptReason? PromptReason { get; } + + private ExecApprovalV2EvaluationOutcome( + ExecApprovalV2EvaluationKind kind, + ExecApprovalV2Result? denial = null, + ExecApprovalV2PromptReason? promptReason = null) + { + Kind = kind; + Denial = denial; + PromptReason = promptReason; + } + + public static ExecApprovalV2EvaluationOutcome Allowed() + => new(ExecApprovalV2EvaluationKind.Allowed); + + public static ExecApprovalV2EvaluationOutcome Denied(ExecApprovalV2Result denial) + => new(ExecApprovalV2EvaluationKind.Denied, denial: denial); + + public static ExecApprovalV2EvaluationOutcome NeedsPrompt(ExecApprovalV2PromptReason reason) + => new(ExecApprovalV2EvaluationKind.NeedsPrompt, promptReason: reason); + + public override string ToString() + => Kind switch + { + ExecApprovalV2EvaluationKind.Denied => $"Denied: {Denial}", + ExecApprovalV2EvaluationKind.NeedsPrompt => $"NeedsPrompt({PromptReason})", + _ => "Allowed", + }; +} + +public enum ExecApprovalV2EvaluationKind +{ + Allowed, + Denied, + NeedsPrompt, +} + +/// +/// Reason the evaluator requires a user prompt. +/// The coordinator uses this to set appropriate UI context. +/// +public enum ExecApprovalV2PromptReason +{ + /// Command is not on the allowlist and ask=on-miss. + AllowlistMiss, + + /// Ask policy is set to always ask regardless of allowlist status. + Always, +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsAllowlistMatcher.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsAllowlistMatcher.cs new file mode 100644 index 0000000..cd331f8 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsAllowlistMatcher.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace OpenClaw.Shared.ExecApprovals; + +/// +/// Matches a single pattern against one or more +/// segments produced by the normalizer. +/// +/// +/// Pattern semantics (case-insensitive on all comparisons): +/// +/// Patterns containing a path separator are compared against the resolved full path. +/// Bare-name patterns (no separator) are compared against the executable basename, +/// both with and without the .exe suffix, to handle Windows conventions. +/// * is a wildcard matching any sequence of characters that does not +/// contain a path separator. This handles patterns like python* or npm*. +/// +/// +public static class ExecApprovalsAllowlistMatcher +{ + /// + /// Returns if matches + /// any resolution segment in . + /// An empty or whitespace-only pattern never matches. + /// + public static bool MatchesAny( + string? pattern, + IReadOnlyList resolutions) + { + if (string.IsNullOrWhiteSpace(pattern) || resolutions.Count == 0) + return false; + + foreach (var resolution in resolutions) + { + if (Matches(pattern, resolution)) + return true; + } + return false; + } + + // Visible for testing. + internal static bool Matches(string pattern, ExecCommandResolution resolution) + { + var trimmedPattern = pattern.Trim(); + if (trimmedPattern.Length == 0) return false; + + bool isPathPattern = trimmedPattern.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, ':']) >= 0; + + if (isPathPattern) + { + return resolution.ResolvedPath is not null + && GlobMatch(trimmedPattern, resolution.ResolvedPath); + } + + // Bare-name pattern: compare against executable basename. + // Try both the ExecutableName (may include .exe) and the name without extension. + var exeName = resolution.ExecutableName; + if (GlobMatch(trimmedPattern, exeName)) return true; + + var nameNoExt = StripExeExtension(exeName); + if (!string.Equals(nameNoExt, exeName, StringComparison.OrdinalIgnoreCase) + && GlobMatch(trimmedPattern, nameNoExt)) + return true; + + // Also compare pattern without .exe against the basename-without-extension + // so "git.exe" pattern matches a resolution with ExecutableName="git.exe". + var patternNoExt = StripExeExtension(trimmedPattern); + if (!string.Equals(patternNoExt, trimmedPattern, StringComparison.OrdinalIgnoreCase)) + { + if (GlobMatch(patternNoExt, exeName)) return true; + if (GlobMatch(patternNoExt, nameNoExt)) return true; + } + + return false; + } + + // Simple glob: supports * (matches any chars excluding path separators) and literal chars. + // Case-insensitive. Does not support ? or character classes — keeping it minimal per research doc 04. + private static bool GlobMatch(string pattern, string value) + { + return GlobMatchAt(pattern.AsSpan(), value.AsSpan()); + } + + private static bool GlobMatchAt(ReadOnlySpan pattern, ReadOnlySpan value) + { + while (true) + { + if (pattern.IsEmpty) return value.IsEmpty; + + if (pattern[0] == '*') + { + // Consume consecutive stars. + var rest = pattern[1..]; + while (!rest.IsEmpty && rest[0] == '*') rest = rest[1..]; + + if (rest.IsEmpty) return true; // trailing star(s) match anything + + // Try matching the rest of the pattern at each position in value. + for (int i = 0; i <= value.Length; i++) + { + // * does not match path separators. + if (i > 0 && (value[i - 1] == Path.DirectorySeparatorChar || value[i - 1] == Path.AltDirectorySeparatorChar)) + break; + if (GlobMatchAt(rest, value[i..])) + return true; + } + return false; + } + + if (value.IsEmpty) return false; + + if (!char.ToLowerInvariant(pattern[0]).Equals(char.ToLowerInvariant(value[0]))) + return false; + + pattern = pattern[1..]; + value = value[1..]; + } + } + + // Strip common Windows executable extensions (.exe, .cmd, .bat, .com) for bare-name matching. + private static string StripExeExtension(string name) + { + if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) return name[..^4]; + if (name.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase)) return name[..^4]; + if (name.EndsWith(".bat", StringComparison.OrdinalIgnoreCase)) return name[..^4]; + if (name.EndsWith(".com", StringComparison.OrdinalIgnoreCase)) return name[..^4]; + return name; + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsEvaluator.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsEvaluator.cs new file mode 100644 index 0000000..d17ec42 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsEvaluator.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; + +namespace OpenClaw.Shared.ExecApprovals; + +/// +/// Phase 3 of the V2 exec approval pipeline: policy evaluation (rail 18, step 3). +/// Stateless — safe to call concurrently. +/// +/// +/// Evaluation cascade: +/// +/// +/// +/// with . +/// +/// +/// . +/// +/// +/// → match each +/// pattern against the command's . +/// On hit → . +/// On miss → apply fallback: +/// +/// . +/// . +/// with . +/// with . +/// +/// +/// +/// When ask=always is set with , an allowlist hit +/// still proceeds to a prompt — the allowlist only pre-populates the "Allow Always" suggestion in the UI. +/// +public static class ExecApprovalsEvaluator +{ + /// + /// Evaluates whether should be allowed, denied, or shown to the user. + /// + /// Canonical command produced by the normalizer. + /// Fully-resolved policy from the store. + public static ExecApprovalV2EvaluationOutcome Evaluate( + CanonicalCommandIdentity identity, + ExecApprovalsResolved resolved) + { + var defaults = resolved.Defaults; + + switch (defaults.Security) + { + case ExecSecurity.Deny: + return ExecApprovalV2EvaluationOutcome.Denied( + ExecApprovalV2Result.SecurityDeny("security-deny")); + + case ExecSecurity.Full: + return EvaluateAskAlways(defaults); + + case ExecSecurity.Allowlist: + return EvaluateAllowlist(identity.AllowlistResolutions, resolved.Allowlist, defaults); + + default: + // Unknown security level — fail closed (research doc 04 R2). + return ExecApprovalV2EvaluationOutcome.Denied( + ExecApprovalV2Result.SecurityDeny("unknown-security-level")); + } + } + + private static ExecApprovalV2EvaluationOutcome EvaluateAskAlways( + ExecApprovalsResolvedDefaults defaults) + { + // Even under Full security, ask=always forces a prompt. + return defaults.Ask == ExecAsk.Always + ? ExecApprovalV2EvaluationOutcome.NeedsPrompt(ExecApprovalV2PromptReason.Always) + : ExecApprovalV2EvaluationOutcome.Allowed(); + } + + private static ExecApprovalV2EvaluationOutcome EvaluateAllowlist( + IReadOnlyList allowlistResolutions, + IReadOnlyList allowlist, + ExecApprovalsResolvedDefaults defaults) + { + // ask=always forces a prompt regardless of allowlist status. + if (defaults.Ask == ExecAsk.Always) + return ExecApprovalV2EvaluationOutcome.NeedsPrompt(ExecApprovalV2PromptReason.Always); + + // Fail-closed: if there are no resolutions, allowlist cannot be satisfied. + if (allowlistResolutions.Count == 0) + return DenyAllowlistMiss(defaults); + + // Check each allowlist entry against the resolved segments. + foreach (var entry in allowlist) + { + if (ExecApprovalsAllowlistMatcher.MatchesAny(entry.Pattern, allowlistResolutions)) + return ExecApprovalV2EvaluationOutcome.Allowed(); + } + + return DenyAllowlistMiss(defaults); + } + + private static ExecApprovalV2EvaluationOutcome DenyAllowlistMiss( + ExecApprovalsResolvedDefaults defaults) + { + return defaults.Ask switch + { + ExecAsk.Off => ExecApprovalV2EvaluationOutcome.Denied( + ExecApprovalV2Result.AllowlistMiss("allowlist-miss")), + + ExecAsk.Deny => ExecApprovalV2EvaluationOutcome.Denied( + ExecApprovalV2Result.SecurityDeny("ask-deny")), + + ExecAsk.OnMiss => ExecApprovalV2EvaluationOutcome.NeedsPrompt( + ExecApprovalV2PromptReason.AllowlistMiss), + + // ask=always is handled before this point, but fall through to NeedsPrompt for safety. + _ => ExecApprovalV2EvaluationOutcome.NeedsPrompt( + ExecApprovalV2PromptReason.AllowlistMiss), + }; + } +} diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalsEvaluatorTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalsEvaluatorTests.cs new file mode 100644 index 0000000..164f1d0 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/ExecApprovalsEvaluatorTests.cs @@ -0,0 +1,390 @@ +using System.Collections.Generic; +using OpenClaw.Shared.ExecApprovals; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +// Tests for PR5: ExecApprovalsEvaluator and ExecApprovalsAllowlistMatcher. +// Coverage: security-level cascade, allowlist pattern matching, ask-mode fallback, +// empty-allowlist fail-closed, edge cases. +public class ExecApprovalsEvaluatorTests +{ + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + // On Linux, Path.GetFileName does not split Windows backslash paths. + // Simulate what ExecCommandResolver does on Windows by splitting on both separators. + private static string WindowsBasename(string path) + { + var parts = path.Split(['\\', '/'], System.StringSplitOptions.RemoveEmptyEntries); + return parts.Length > 0 ? parts[^1] : path; + } + + private static ExecCommandResolution Res(string exe, string? resolved = null) + => new(exe, resolved, WindowsBasename(resolved ?? exe), null); + + private static ExecAllowlistEntry Entry(string pattern) + => new() { Pattern = pattern }; + + private static ExecApprovalsResolvedDefaults Defaults( + ExecSecurity security = ExecSecurity.Allowlist, + ExecAsk ask = ExecAsk.Off, + ExecAsk askFallback = ExecAsk.Off) + => new() + { + Security = security, + Ask = ask, + AskFallback = askFallback, + AutoAllowSkills = false, + }; + + private static ExecApprovalsResolved Resolved( + ExecApprovalsResolvedDefaults defaults, + IReadOnlyList? allowlist = null, + string agentId = "agent1") + => new() + { + AgentId = agentId, + Defaults = defaults, + Allowlist = allowlist ?? [], + }; + + private static CanonicalCommandIdentity Identity( + IReadOnlyList resolutions, + string? agentId = "agent1") + => new( + command: ["git", "status"], + displayCommand: "git status", + evaluationRawCommand: null, + resolution: resolutions.Count > 0 ? resolutions[0] : null, + allowlistResolutions: resolutions, + allowAlwaysPatterns: [], + cwd: null, + timeoutMs: 30_000, + env: null, + agentId: agentId, + sessionKey: null); + + // ------------------------------------------------------------------------- + // 1. Security level: Deny + // ------------------------------------------------------------------------- + + [Fact] + public void SecurityDeny_AlwaysReturnsSecurityDeny() + { + var resolved = Resolved(Defaults(security: ExecSecurity.Deny)); + var identity = Identity([Res("git.exe", @"C:\Program Files\Git\bin\git.exe")]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.Denied, outcome.Kind); + Assert.Equal(ExecApprovalV2Code.SecurityDeny, outcome.Denial!.Code); + } + + [Fact] + public void SecurityDeny_IgnoresAllowlist() + { + var resolved = Resolved( + Defaults(security: ExecSecurity.Deny), + allowlist: [Entry("git")]); + var identity = Identity([Res("git.exe", @"C:\Program Files\Git\bin\git.exe")]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.Denied, outcome.Kind); + } + + // ------------------------------------------------------------------------- + // 2. Security level: Full + // ------------------------------------------------------------------------- + + [Fact] + public void SecurityFull_AllowsWhenAskIsOff() + { + var resolved = Resolved(Defaults(security: ExecSecurity.Full, ask: ExecAsk.Off)); + var identity = Identity([Res("notepad.exe", @"C:\Windows\notepad.exe")]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.Allowed, outcome.Kind); + } + + [Fact] + public void SecurityFull_NeedsPrompt_WhenAskIsAlways() + { + var resolved = Resolved(Defaults(security: ExecSecurity.Full, ask: ExecAsk.Always)); + var identity = Identity([Res("notepad.exe")]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.NeedsPrompt, outcome.Kind); + Assert.Equal(ExecApprovalV2PromptReason.Always, outcome.PromptReason); + } + + // ------------------------------------------------------------------------- + // 3. Security level: Allowlist — allowlist hit + // ------------------------------------------------------------------------- + + [Fact] + public void AllowlistHit_ByExactBasename() + { + var resolved = Resolved( + Defaults(security: ExecSecurity.Allowlist, ask: ExecAsk.Off), + allowlist: [Entry("git")]); + var identity = Identity([Res("git.exe", @"C:\Program Files\Git\bin\git.exe")]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.Allowed, outcome.Kind); + } + + [Fact] + public void AllowlistHit_ByExactBasenameWithExeExtension() + { + var resolved = Resolved( + Defaults(security: ExecSecurity.Allowlist, ask: ExecAsk.Off), + allowlist: [Entry("git.exe")]); + var identity = Identity([Res("git.exe", @"C:\Program Files\Git\bin\git.exe")]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.Allowed, outcome.Kind); + } + + [Fact] + public void AllowlistHit_ByFullResolvedPath() + { + var resolved = Resolved( + Defaults(security: ExecSecurity.Allowlist, ask: ExecAsk.Off), + allowlist: [Entry(@"C:\Program Files\Git\bin\git.exe")]); + var identity = Identity([Res("git.exe", @"C:\Program Files\Git\bin\git.exe")]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.Allowed, outcome.Kind); + } + + [Fact] + public void AllowlistHit_ByGlobPattern() + { + var resolved = Resolved( + Defaults(security: ExecSecurity.Allowlist, ask: ExecAsk.Off), + allowlist: [Entry("python*")]); + var identity = Identity([Res("python3.11.exe", @"C:\Python311\python3.11.exe")]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.Allowed, outcome.Kind); + } + + [Fact] + public void AllowlistHit_CaseInsensitive() + { + var resolved = Resolved( + Defaults(security: ExecSecurity.Allowlist, ask: ExecAsk.Off), + allowlist: [Entry("GIT")]); + var identity = Identity([Res("git.exe", @"C:\Program Files\Git\bin\git.exe")]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.Allowed, outcome.Kind); + } + + [Fact] + public void AllowlistHit_MultiSegmentChain_AnySegmentMatches() + { + // Shell chain: "git status && npm install" — two segments, only npm is on the allowlist. + var resolved = Resolved( + Defaults(security: ExecSecurity.Allowlist, ask: ExecAsk.Off), + allowlist: [Entry("npm")]); + var identity = Identity([ + Res("git.exe", @"C:\Program Files\Git\bin\git.exe"), + Res("npm.cmd", @"C:\Program Files\nodejs\npm.cmd"), + ]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + // Only one segment matches — the chain as a whole is allowed. + // (Full chain-wide matching is enforced by the coordinator, not the evaluator.) + Assert.Equal(ExecApprovalV2EvaluationKind.Allowed, outcome.Kind); + } + + // ------------------------------------------------------------------------- + // 4. Security level: Allowlist — allowlist miss + Ask fallback + // ------------------------------------------------------------------------- + + [Fact] + public void AllowlistMiss_AskOff_ReturnsDenied_AllowlistMiss() + { + var resolved = Resolved( + Defaults(security: ExecSecurity.Allowlist, ask: ExecAsk.Off), + allowlist: [Entry("npm")]); + var identity = Identity([Res("git.exe", @"C:\Program Files\Git\bin\git.exe")]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.Denied, outcome.Kind); + Assert.Equal(ExecApprovalV2Code.AllowlistMiss, outcome.Denial!.Code); + } + + [Fact] + public void AllowlistMiss_AskDeny_ReturnsDenied_SecurityDeny() + { + var resolved = Resolved( + Defaults(security: ExecSecurity.Allowlist, ask: ExecAsk.Deny), + allowlist: []); + var identity = Identity([Res("git.exe")]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.Denied, outcome.Kind); + Assert.Equal(ExecApprovalV2Code.SecurityDeny, outcome.Denial!.Code); + } + + [Fact] + public void AllowlistMiss_AskOnMiss_ReturnsNeedsPrompt_AllowlistMiss() + { + var resolved = Resolved( + Defaults(security: ExecSecurity.Allowlist, ask: ExecAsk.OnMiss), + allowlist: []); + var identity = Identity([Res("curl.exe")]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.NeedsPrompt, outcome.Kind); + Assert.Equal(ExecApprovalV2PromptReason.AllowlistMiss, outcome.PromptReason); + } + + [Fact] + public void AskAlways_AllowlistSecurity_NeedsPrompt_EvenOnHit() + { + // ask=always means prompt regardless of allowlist status. + var resolved = Resolved( + Defaults(security: ExecSecurity.Allowlist, ask: ExecAsk.Always), + allowlist: [Entry("git")]); + var identity = Identity([Res("git.exe", @"C:\Program Files\Git\bin\git.exe")]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.NeedsPrompt, outcome.Kind); + Assert.Equal(ExecApprovalV2PromptReason.Always, outcome.PromptReason); + } + + // ------------------------------------------------------------------------- + // 5. Fail-closed: empty resolutions + // ------------------------------------------------------------------------- + + [Fact] + public void EmptyResolutions_FailClosed_AllowlistMiss() + { + var resolved = Resolved( + Defaults(security: ExecSecurity.Allowlist, ask: ExecAsk.Off), + allowlist: [Entry("git")]); + // Empty resolutions: the normalizer could not resolve any segment. + var identity = Identity([]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.Denied, outcome.Kind); + Assert.Equal(ExecApprovalV2Code.AllowlistMiss, outcome.Denial!.Code); + } + + [Fact] + public void EmptyResolutions_AskOnMiss_NeedsPrompt() + { + var resolved = Resolved( + Defaults(security: ExecSecurity.Allowlist, ask: ExecAsk.OnMiss), + allowlist: [Entry("git")]); + var identity = Identity([]); + + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + + Assert.Equal(ExecApprovalV2EvaluationKind.NeedsPrompt, outcome.Kind); + } + + // ------------------------------------------------------------------------- + // 6. AllowlistMatcher edge cases + // ------------------------------------------------------------------------- + + [Theory] + [InlineData("git", "git.exe", null, true)] + [InlineData("git.exe", "git.exe", null, true)] + [InlineData("GIT", "git.exe", null, true)] + [InlineData("node", "npm.cmd", null, false)] + [InlineData("python*", "python3.11.exe", null, true)] + [InlineData("python*", "python.exe", null, true)] + [InlineData("python*", "node.exe", null, false)] + [InlineData("npm*", "npm.cmd", null, true)] + [InlineData("", "git.exe", null, false)] + [InlineData(" ", "git.exe", null, false)] + public void AllowlistMatcher_MatchesAny(string pattern, string exe, string? resolved, bool expected) + { + var resolutions = new[] { Res(exe, resolved) }; + Assert.Equal(expected, ExecApprovalsAllowlistMatcher.MatchesAny(pattern, resolutions)); + } + + [Fact] + public void AllowlistMatcher_FullPathPattern_MatchesResolvedPath() + { + var res = Res("git.exe", @"C:\Program Files\Git\bin\git.exe"); + Assert.True(ExecApprovalsAllowlistMatcher.MatchesAny( + @"C:\Program Files\Git\bin\git.exe", [res])); + } + + [Fact] + public void AllowlistMatcher_FullPathPattern_DoesNotMatchWrongPath() + { + var res = Res("git.exe", @"C:\Program Files\Git\bin\git.exe"); + Assert.False(ExecApprovalsAllowlistMatcher.MatchesAny( + @"C:\Users\attacker\git.exe", [res])); + } + + [Fact] + public void AllowlistMatcher_EmptyAllowlist_NeverMatches() + { + var res = Res("git.exe", @"C:\Program Files\Git\bin\git.exe"); + Assert.False(ExecApprovalsAllowlistMatcher.MatchesAny("git", [])); + } + + [Fact] + public void AllowlistMatcher_NullPattern_NeverMatches() + { + var res = Res("git.exe", @"C:\Program Files\Git\bin\git.exe"); + Assert.False(ExecApprovalsAllowlistMatcher.MatchesAny(null, [res])); + } + + // ------------------------------------------------------------------------- + // 7. Outcome shape + // ------------------------------------------------------------------------- + + [Fact] + public void AllowedOutcome_DenialIsNull() + { + var resolved = Resolved(Defaults(security: ExecSecurity.Full, ask: ExecAsk.Off)); + var identity = Identity([Res("cmd.exe")]); + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + Assert.Null(outcome.Denial); + Assert.Null(outcome.PromptReason); + } + + [Fact] + public void DeniedOutcome_PromptReasonIsNull() + { + var resolved = Resolved(Defaults(security: ExecSecurity.Deny)); + var identity = Identity([Res("cmd.exe")]); + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + Assert.Null(outcome.PromptReason); + Assert.NotNull(outcome.Denial); + } + + [Fact] + public void NeedsPromptOutcome_DenialIsNull() + { + var resolved = Resolved(Defaults(security: ExecSecurity.Allowlist, ask: ExecAsk.OnMiss)); + var identity = Identity([Res("cmd.exe")]); + var outcome = ExecApprovalsEvaluator.Evaluate(identity, resolved); + Assert.Equal(ExecApprovalV2EvaluationKind.NeedsPrompt, outcome.Kind); + Assert.Null(outcome.Denial); + } +}