feat: add ExecApprovalsEvaluator policy evaluation step

Implements Phase 3 of the V2 exec approval pipeline: policy evaluation.

## What's added

- **ExecApprovalsEvaluator** — stateless evaluator that takes a
  CanonicalCommandIdentity (from the normalizer) and an
  ExecApprovalsResolved (from the store) and returns a typed
  ExecApprovalV2EvaluationOutcome. Covers the full security-level
  cascade described in research doc 04:
  - ExecSecurity.Deny     → immediate SecurityDeny
  - ExecSecurity.Full     → Allowed (or NeedsPrompt if ask=always)
  - ExecSecurity.Allowlist → match allowlist, then apply Ask fallback

- **ExecApprovalsAllowlistMatcher** — pattern matching against
  ExecCommandResolution segments:
  - Bare-name patterns (e.g. "git", "npm") matched against executable
    basename with and without extension (.exe/.cmd/.bat/.com), case-insensitive
  - Full-path patterns compared against ResolvedPath (case-insensitive)
  - Glob wildcard * supported (e.g. "python*" matches "python3.11.exe")
  - * does not cross path separators (safe against escape attempts)

- **ExecApprovalV2EvaluationOutcome** — discriminated result type:
  Allowed / Denied(ExecApprovalV2Result) / NeedsPrompt(PromptReason)

- **33 tests** covering security levels, allowlist hits/misses, ask-mode
  fallbacks, multi-segment chains, glob matching, and edge cases.

## Notes

This PR is based on top of PR #295 (ExecApprovalsStore read path) and
requires its contract types (ExecSecurity, ExecAsk, ExecApprovalsResolved,
ExecAllowlistEntry, ExecApprovalsResolvedDefaults). Merge #295 first.

## Test Status

- OpenClaw.Shared.Tests: 1479 passed / 4 pre-existing failures / 20 skipped
  (same 4 failures as baseline on Linux: ExecApprovalV2Normalization ×3,
   McpHttpServerTests ×1)
- build.ps1: requires Windows; infrastructure limitation on Linux runner

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
github-actions[bot] 2026-05-08 13:05:14 +00:00 committed by GitHub
parent 7c63ba862d
commit 998aa3493f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 702 additions and 0 deletions

View File

@ -0,0 +1,63 @@
namespace OpenClaw.Shared.ExecApprovals;
/// <summary>
/// Discriminated outcome from <see cref="ExecApprovalsEvaluator"/>.
/// The coordinator consumes this and either proceeds, rejects, or shows a prompt.
/// </summary>
public sealed class ExecApprovalV2EvaluationOutcome
{
public ExecApprovalV2EvaluationKind Kind { get; }
/// <summary>Non-null when <see cref="Kind"/> is <see cref="ExecApprovalV2EvaluationKind.Denied"/>.</summary>
public ExecApprovalV2Result? Denial { get; }
/// <summary>Non-null when <see cref="Kind"/> is <see cref="ExecApprovalV2EvaluationKind.NeedsPrompt"/>.</summary>
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,
}
/// <summary>
/// Reason the evaluator requires a user prompt.
/// The coordinator uses this to set appropriate UI context.
/// </summary>
public enum ExecApprovalV2PromptReason
{
/// <summary>Command is not on the allowlist and ask=on-miss.</summary>
AllowlistMiss,
/// <summary>Ask policy is set to always ask regardless of allowlist status.</summary>
Always,
}

View File

@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace OpenClaw.Shared.ExecApprovals;
/// <summary>
/// Matches a single <see cref="ExecAllowlistEntry"/> pattern against one or more
/// <see cref="ExecCommandResolution"/> segments produced by the normalizer.
/// </summary>
/// <remarks>
/// Pattern semantics (case-insensitive on all comparisons):
/// <list type="bullet">
/// <item>Patterns containing a path separator are compared against the resolved full path.</item>
/// <item>Bare-name patterns (no separator) are compared against the executable basename,
/// both with and without the <c>.exe</c> suffix, to handle Windows conventions.</item>
/// <item><c>*</c> is a wildcard matching any sequence of characters that does not
/// contain a path separator. This handles patterns like <c>python*</c> or <c>npm*</c>.</item>
/// </list>
/// </remarks>
public static class ExecApprovalsAllowlistMatcher
{
/// <summary>
/// Returns <see langword="true"/> if <paramref name="pattern"/> matches
/// any resolution segment in <paramref name="resolutions"/>.
/// An empty or whitespace-only pattern never matches.
/// </summary>
public static bool MatchesAny(
string? pattern,
IReadOnlyList<ExecCommandResolution> 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<char> pattern, ReadOnlySpan<char> 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;
}
}

View File

@ -0,0 +1,118 @@
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
/// <summary>
/// Phase 3 of the V2 exec approval pipeline: policy evaluation (rail 18, step 3).
/// Stateless — safe to call concurrently.
/// </summary>
/// <remarks>
/// Evaluation cascade:
/// <list type="number">
/// <item>
/// <see cref="ExecSecurity.Deny"/> → <see cref="ExecApprovalV2EvaluationKind.Denied"/>
/// with <see cref="ExecApprovalV2Code.SecurityDeny"/>.
/// </item>
/// <item>
/// <see cref="ExecSecurity.Full"/> → <see cref="ExecApprovalV2EvaluationKind.Allowed"/>.
/// </item>
/// <item>
/// <see cref="ExecSecurity.Allowlist"/> → match each <see cref="ExecAllowlistEntry"/>
/// pattern against the command's <see cref="CanonicalCommandIdentity.AllowlistResolutions"/>.
/// On hit → <see cref="ExecApprovalV2EvaluationKind.Allowed"/>.
/// On miss → apply <see cref="ExecAsk"/> fallback:
/// <list type="bullet">
/// <item><see cref="ExecAsk.Off"/> → <see cref="ExecApprovalV2Code.AllowlistMiss"/>.</item>
/// <item><see cref="ExecAsk.Deny"/> → <see cref="ExecApprovalV2Code.SecurityDeny"/>.</item>
/// <item><see cref="ExecAsk.OnMiss"/> → <see cref="ExecApprovalV2EvaluationKind.NeedsPrompt"/> with <see cref="ExecApprovalV2PromptReason.AllowlistMiss"/>.</item>
/// <item><see cref="ExecAsk.Always"/> → <see cref="ExecApprovalV2EvaluationKind.NeedsPrompt"/> with <see cref="ExecApprovalV2PromptReason.Always"/>.</item>
/// </list>
/// </item>
/// </list>
/// When <c>ask=always</c> is set with <see cref="ExecSecurity.Allowlist"/>, an allowlist hit
/// still proceeds to a prompt — the allowlist only pre-populates the "Allow Always" suggestion in the UI.
/// </remarks>
public static class ExecApprovalsEvaluator
{
/// <summary>
/// Evaluates whether <paramref name="identity"/> should be allowed, denied, or shown to the user.
/// </summary>
/// <param name="identity">Canonical command produced by the normalizer.</param>
/// <param name="resolved">Fully-resolved policy from the store.</param>
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<ExecCommandResolution> allowlistResolutions,
IReadOnlyList<ExecAllowlistEntry> 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),
};
}
}

View File

@ -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<ExecAllowlistEntry>? allowlist = null,
string agentId = "agent1")
=> new()
{
AgentId = agentId,
Defaults = defaults,
Allowlist = allowlist ?? [],
};
private static CanonicalCommandIdentity Identity(
IReadOnlyList<ExecCommandResolution> 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);
}
}