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:
parent
7c63ba862d
commit
998aa3493f
@ -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,
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
118
src/OpenClaw.Shared/ExecApprovals/ExecApprovalsEvaluator.cs
Normal file
118
src/OpenClaw.Shared/ExecApprovals/ExecApprovalsEvaluator.cs
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
390
tests/OpenClaw.Shared.Tests/ExecApprovalsEvaluatorTests.cs
Normal file
390
tests/OpenClaw.Shared.Tests/ExecApprovalsEvaluatorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user