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);
+ }
+}