This commit is contained in:
StellaOps Bot
2025-11-27 21:10:06 +02:00
parent cfa2274d31
commit 8abbf9574d
106 changed files with 7078 additions and 3197 deletions

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Immutable;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Compilation;

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Evaluation;
@@ -11,13 +11,13 @@ internal sealed record PolicyEvaluationRequest(
PolicyIrDocument Document,
PolicyEvaluationContext Context);
internal sealed record PolicyEvaluationContext(
PolicyEvaluationSeverity Severity,
PolicyEvaluationEnvironment Environment,
PolicyEvaluationAdvisory Advisory,
PolicyEvaluationVexEvidence Vex,
PolicyEvaluationSbom Sbom,
PolicyEvaluationExceptions Exceptions);
internal sealed record PolicyEvaluationContext(
PolicyEvaluationSeverity Severity,
PolicyEvaluationEnvironment Environment,
PolicyEvaluationAdvisory Advisory,
PolicyEvaluationVexEvidence Vex,
PolicyEvaluationSbom Sbom,
PolicyEvaluationExceptions Exceptions);
internal sealed record PolicyEvaluationSeverity(string Normalized, decimal? Score = null);
@@ -43,28 +43,28 @@ internal sealed record PolicyEvaluationVexStatement(
string StatementId,
DateTimeOffset? Timestamp = null);
internal sealed record PolicyEvaluationSbom(
ImmutableHashSet<string> Tags,
ImmutableArray<PolicyEvaluationComponent> Components)
{
public PolicyEvaluationSbom(ImmutableHashSet<string> Tags)
: this(Tags, ImmutableArray<PolicyEvaluationComponent>.Empty)
{
}
public static readonly PolicyEvaluationSbom Empty = new(
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableArray<PolicyEvaluationComponent>.Empty);
public bool HasTag(string tag) => Tags.Contains(tag);
}
internal sealed record PolicyEvaluationComponent(
string Name,
string Version,
string Type,
string? Purl,
ImmutableDictionary<string, string> Metadata);
internal sealed record PolicyEvaluationSbom(
ImmutableHashSet<string> Tags,
ImmutableArray<PolicyEvaluationComponent> Components)
{
public PolicyEvaluationSbom(ImmutableHashSet<string> Tags)
: this(Tags, ImmutableArray<PolicyEvaluationComponent>.Empty)
{
}
public static readonly PolicyEvaluationSbom Empty = new(
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableArray<PolicyEvaluationComponent>.Empty);
public bool HasTag(string tag) => Tags.Contains(tag);
}
internal sealed record PolicyEvaluationComponent(
string Name,
string Version,
string Type,
string? Purl,
ImmutableDictionary<string, string> Metadata);
internal sealed record PolicyEvaluationResult(
bool Matched,

View File

@@ -4,7 +4,7 @@ using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Evaluation;

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Evaluation;
@@ -98,20 +98,20 @@ internal sealed class PolicyExpressionEvaluator
return sbom.Get(member.Member);
}
if (raw is ComponentScope componentScope)
{
return componentScope.Get(member.Member);
}
if (raw is RubyComponentScope rubyScope)
{
return rubyScope.Get(member.Member);
}
if (raw is ImmutableDictionary<string, object?> dict && dict.TryGetValue(member.Member, out var value))
{
return new EvaluationValue(value);
}
if (raw is ComponentScope componentScope)
{
return componentScope.Get(member.Member);
}
if (raw is RubyComponentScope rubyScope)
{
return rubyScope.Get(member.Member);
}
if (raw is ImmutableDictionary<string, object?> dict && dict.TryGetValue(member.Member, out var value))
{
return new EvaluationValue(value);
}
if (raw is PolicyEvaluationVexStatement stmt)
{
@@ -139,51 +139,51 @@ internal sealed class PolicyExpressionEvaluator
}
}
if (invocation.Target is PolicyMemberAccessExpression member)
{
var targetValue = Evaluate(member.Target, scope);
var targetRaw = targetValue.Raw;
if (targetRaw is RubyComponentScope rubyScope)
{
return rubyScope.Invoke(member.Member, invocation.Arguments, scope, this);
}
if (targetRaw is ComponentScope componentScope)
{
return componentScope.Invoke(member.Member, invocation.Arguments, scope, this);
}
if (member.Target is PolicyIdentifierExpression root)
{
if (root.Name == "vex" && targetRaw is VexScope vexScope)
{
return member.Member switch
{
"any" => new EvaluationValue(vexScope.Any(invocation.Arguments, scope)),
"latest" => new EvaluationValue(vexScope.Latest()),
_ => EvaluationValue.Null,
};
}
if (root.Name == "sbom" && targetRaw is SbomScope sbomScope)
{
return member.Member switch
{
"has_tag" => sbomScope.HasTag(invocation.Arguments, scope, this),
"any_component" => sbomScope.AnyComponent(invocation.Arguments, scope, this),
_ => EvaluationValue.Null,
};
}
if (root.Name == "advisory" && targetRaw is AdvisoryScope advisoryScope)
{
return advisoryScope.Invoke(member.Member, invocation.Arguments, scope, this);
}
}
}
return EvaluationValue.Null;
}
if (invocation.Target is PolicyMemberAccessExpression member)
{
var targetValue = Evaluate(member.Target, scope);
var targetRaw = targetValue.Raw;
if (targetRaw is RubyComponentScope rubyScope)
{
return rubyScope.Invoke(member.Member, invocation.Arguments, scope, this);
}
if (targetRaw is ComponentScope componentScope)
{
return componentScope.Invoke(member.Member, invocation.Arguments, scope, this);
}
if (member.Target is PolicyIdentifierExpression root)
{
if (root.Name == "vex" && targetRaw is VexScope vexScope)
{
return member.Member switch
{
"any" => new EvaluationValue(vexScope.Any(invocation.Arguments, scope)),
"latest" => new EvaluationValue(vexScope.Latest()),
_ => EvaluationValue.Null,
};
}
if (root.Name == "sbom" && targetRaw is SbomScope sbomScope)
{
return member.Member switch
{
"has_tag" => sbomScope.HasTag(invocation.Arguments, scope, this),
"any_component" => sbomScope.AnyComponent(invocation.Arguments, scope, this),
_ => EvaluationValue.Null,
};
}
if (root.Name == "advisory" && targetRaw is AdvisoryScope advisoryScope)
{
return advisoryScope.Invoke(member.Member, invocation.Arguments, scope, this);
}
}
}
return EvaluationValue.Null;
}
private EvaluationValue EvaluateIndexer(PolicyIndexerExpression indexer, EvaluationScope scope)
{
@@ -442,322 +442,322 @@ internal sealed class PolicyExpressionEvaluator
this.sbom = sbom;
}
public EvaluationValue Get(string member)
{
if (member.Equals("tags", StringComparison.OrdinalIgnoreCase))
{
return new EvaluationValue(sbom.Tags.ToImmutableArray<object?>());
}
if (member.Equals("components", StringComparison.OrdinalIgnoreCase))
{
return new EvaluationValue(sbom.Components
.Select(component => (object?)new ComponentScope(component))
.ToImmutableArray());
}
return EvaluationValue.Null;
}
public EvaluationValue HasTag(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
var tag = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
if (string.IsNullOrWhiteSpace(tag))
{
return EvaluationValue.False;
}
return new EvaluationValue(sbom.HasTag(tag!));
}
public EvaluationValue AnyComponent(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
if (arguments.Length == 0 || sbom.Components.IsDefaultOrEmpty)
{
return EvaluationValue.False;
}
var predicate = arguments[0];
foreach (var component in sbom.Components)
{
var locals = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["component"] = new ComponentScope(component),
};
if (component.Type.Equals("gem", StringComparison.OrdinalIgnoreCase))
{
locals["ruby"] = new RubyComponentScope(component);
}
var nestedScope = EvaluationScope.FromLocals(scope.Globals, locals);
if (evaluator.EvaluateBoolean(predicate, nestedScope))
{
return EvaluationValue.True;
}
}
return EvaluationValue.False;
}
}
private sealed class ComponentScope
{
private readonly PolicyEvaluationComponent component;
public ComponentScope(PolicyEvaluationComponent component)
{
this.component = component;
}
public EvaluationValue Get(string member)
{
return member.ToLowerInvariant() switch
{
"name" => new EvaluationValue(component.Name),
"version" => new EvaluationValue(component.Version),
"type" => new EvaluationValue(component.Type),
"purl" => new EvaluationValue(component.Purl),
"metadata" => new EvaluationValue(component.Metadata),
_ => component.Metadata.TryGetValue(member, out var value)
? new EvaluationValue(value)
: EvaluationValue.Null,
};
}
public EvaluationValue Invoke(string member, ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
if (member.Equals("has_metadata", StringComparison.OrdinalIgnoreCase))
{
var key = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
if (string.IsNullOrWhiteSpace(key))
{
return EvaluationValue.False;
}
return new EvaluationValue(component.Metadata.ContainsKey(key!));
}
return EvaluationValue.Null;
}
}
private sealed class RubyComponentScope
{
private readonly PolicyEvaluationComponent component;
private readonly ImmutableHashSet<string> groups;
public RubyComponentScope(PolicyEvaluationComponent component)
{
this.component = component;
groups = ParseGroups(component.Metadata);
}
public EvaluationValue Get(string member)
{
return member.ToLowerInvariant() switch
{
"groups" => new EvaluationValue(groups.Select(value => (object?)value).ToImmutableArray()),
"declaredonly" => new EvaluationValue(IsDeclaredOnly()),
"source" => new EvaluationValue(GetSource() ?? string.Empty),
_ => component.Metadata.TryGetValue(member, out var value)
? new EvaluationValue(value)
: EvaluationValue.Null,
};
}
public EvaluationValue Invoke(string member, ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
switch (member.ToLowerInvariant())
{
case "group":
{
var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
return new EvaluationValue(name is not null && groups.Contains(name));
}
case "groups":
return new EvaluationValue(groups.Select(value => (object?)value).ToImmutableArray());
case "declared_only":
return new EvaluationValue(IsDeclaredOnly());
case "source":
{
if (arguments.Length == 0)
{
return new EvaluationValue(GetSource() ?? string.Empty);
}
var requested = evaluator.Evaluate(arguments[0], scope).AsString();
if (string.IsNullOrWhiteSpace(requested))
{
return EvaluationValue.False;
}
var kind = GetSourceKind();
return new EvaluationValue(string.Equals(kind, requested, StringComparison.OrdinalIgnoreCase));
}
case "capability":
{
var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
return new EvaluationValue(HasCapability(name));
}
case "capability_any":
{
var capabilities = EvaluateAsStringSet(arguments, scope, evaluator);
return new EvaluationValue(capabilities.Any(HasCapability));
}
default:
return EvaluationValue.Null;
}
}
private bool HasCapability(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
var normalized = name.Trim();
if (normalized.Length == 0)
{
return false;
}
if (component.Metadata.TryGetValue($"capability.{normalized}", out var value))
{
return IsTruthy(value);
}
if (normalized.StartsWith("scheduler.", StringComparison.OrdinalIgnoreCase))
{
var group = normalized.Substring("scheduler.".Length);
var schedulerList = component.Metadata.TryGetValue("capability.scheduler", out var listValue)
? listValue
: null;
return ContainsDelimitedValue(schedulerList, group);
}
if (normalized.Equals("scheduler", StringComparison.OrdinalIgnoreCase))
{
var schedulerList = component.Metadata.TryGetValue("capability.scheduler", out var listValue)
? listValue
: null;
return !string.IsNullOrWhiteSpace(schedulerList);
}
return false;
}
private bool IsDeclaredOnly()
{
return component.Metadata.TryGetValue("declaredOnly", out var value) && IsTruthy(value);
}
private string? GetSource()
{
return component.Metadata.TryGetValue("source", out var value) ? value : null;
}
private string? GetSourceKind()
{
var source = GetSource();
if (string.IsNullOrWhiteSpace(source))
{
return null;
}
source = source.Trim();
if (source.StartsWith("git:", StringComparison.OrdinalIgnoreCase))
{
return "git";
}
if (source.StartsWith("path:", StringComparison.OrdinalIgnoreCase))
{
return "path";
}
if (source.StartsWith("vendor-cache", StringComparison.OrdinalIgnoreCase))
{
return "vendor-cache";
}
if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| source.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return "registry";
}
return source;
}
private static ImmutableHashSet<string> ParseGroups(ImmutableDictionary<string, string> metadata)
{
if (!metadata.TryGetValue("groups", out var value) || string.IsNullOrWhiteSpace(value))
{
return ImmutableHashSet<string>.Empty;
}
var groups = value
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(static g => !string.IsNullOrWhiteSpace(g))
.Select(static g => g.Trim())
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
return groups;
}
private static bool ContainsDelimitedValue(string? delimited, string value)
{
if (string.IsNullOrWhiteSpace(delimited) || string.IsNullOrWhiteSpace(value))
{
return false;
}
return delimited
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(entry => entry.Equals(value, StringComparison.OrdinalIgnoreCase));
}
private static bool IsTruthy(string? value)
{
return value is not null
&& (value.Equals("true", StringComparison.OrdinalIgnoreCase)
|| value.Equals("1", StringComparison.OrdinalIgnoreCase)
|| value.Equals("yes", StringComparison.OrdinalIgnoreCase));
}
private static ImmutableHashSet<string> EvaluateAsStringSet(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
foreach (var argument in arguments)
{
var evaluated = evaluator.Evaluate(argument, scope).Raw;
switch (evaluated)
{
case ImmutableArray<object?> array:
foreach (var item in array)
{
if (item is string text && !string.IsNullOrWhiteSpace(text))
{
builder.Add(text.Trim());
}
}
break;
case string text when !string.IsNullOrWhiteSpace(text):
builder.Add(text.Trim());
break;
}
}
return builder.ToImmutable();
}
}
private sealed class VexScope
{
private readonly PolicyExpressionEvaluator evaluator;
public EvaluationValue Get(string member)
{
if (member.Equals("tags", StringComparison.OrdinalIgnoreCase))
{
return new EvaluationValue(sbom.Tags.ToImmutableArray<object?>());
}
if (member.Equals("components", StringComparison.OrdinalIgnoreCase))
{
return new EvaluationValue(sbom.Components
.Select(component => (object?)new ComponentScope(component))
.ToImmutableArray());
}
return EvaluationValue.Null;
}
public EvaluationValue HasTag(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
var tag = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
if (string.IsNullOrWhiteSpace(tag))
{
return EvaluationValue.False;
}
return new EvaluationValue(sbom.HasTag(tag!));
}
public EvaluationValue AnyComponent(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
if (arguments.Length == 0 || sbom.Components.IsDefaultOrEmpty)
{
return EvaluationValue.False;
}
var predicate = arguments[0];
foreach (var component in sbom.Components)
{
var locals = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["component"] = new ComponentScope(component),
};
if (component.Type.Equals("gem", StringComparison.OrdinalIgnoreCase))
{
locals["ruby"] = new RubyComponentScope(component);
}
var nestedScope = EvaluationScope.FromLocals(scope.Globals, locals);
if (evaluator.EvaluateBoolean(predicate, nestedScope))
{
return EvaluationValue.True;
}
}
return EvaluationValue.False;
}
}
private sealed class ComponentScope
{
private readonly PolicyEvaluationComponent component;
public ComponentScope(PolicyEvaluationComponent component)
{
this.component = component;
}
public EvaluationValue Get(string member)
{
return member.ToLowerInvariant() switch
{
"name" => new EvaluationValue(component.Name),
"version" => new EvaluationValue(component.Version),
"type" => new EvaluationValue(component.Type),
"purl" => new EvaluationValue(component.Purl),
"metadata" => new EvaluationValue(component.Metadata),
_ => component.Metadata.TryGetValue(member, out var value)
? new EvaluationValue(value)
: EvaluationValue.Null,
};
}
public EvaluationValue Invoke(string member, ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
if (member.Equals("has_metadata", StringComparison.OrdinalIgnoreCase))
{
var key = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
if (string.IsNullOrWhiteSpace(key))
{
return EvaluationValue.False;
}
return new EvaluationValue(component.Metadata.ContainsKey(key!));
}
return EvaluationValue.Null;
}
}
private sealed class RubyComponentScope
{
private readonly PolicyEvaluationComponent component;
private readonly ImmutableHashSet<string> groups;
public RubyComponentScope(PolicyEvaluationComponent component)
{
this.component = component;
groups = ParseGroups(component.Metadata);
}
public EvaluationValue Get(string member)
{
return member.ToLowerInvariant() switch
{
"groups" => new EvaluationValue(groups.Select(value => (object?)value).ToImmutableArray()),
"declaredonly" => new EvaluationValue(IsDeclaredOnly()),
"source" => new EvaluationValue(GetSource() ?? string.Empty),
_ => component.Metadata.TryGetValue(member, out var value)
? new EvaluationValue(value)
: EvaluationValue.Null,
};
}
public EvaluationValue Invoke(string member, ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
switch (member.ToLowerInvariant())
{
case "group":
{
var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
return new EvaluationValue(name is not null && groups.Contains(name));
}
case "groups":
return new EvaluationValue(groups.Select(value => (object?)value).ToImmutableArray());
case "declared_only":
return new EvaluationValue(IsDeclaredOnly());
case "source":
{
if (arguments.Length == 0)
{
return new EvaluationValue(GetSource() ?? string.Empty);
}
var requested = evaluator.Evaluate(arguments[0], scope).AsString();
if (string.IsNullOrWhiteSpace(requested))
{
return EvaluationValue.False;
}
var kind = GetSourceKind();
return new EvaluationValue(string.Equals(kind, requested, StringComparison.OrdinalIgnoreCase));
}
case "capability":
{
var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
return new EvaluationValue(HasCapability(name));
}
case "capability_any":
{
var capabilities = EvaluateAsStringSet(arguments, scope, evaluator);
return new EvaluationValue(capabilities.Any(HasCapability));
}
default:
return EvaluationValue.Null;
}
}
private bool HasCapability(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
var normalized = name.Trim();
if (normalized.Length == 0)
{
return false;
}
if (component.Metadata.TryGetValue($"capability.{normalized}", out var value))
{
return IsTruthy(value);
}
if (normalized.StartsWith("scheduler.", StringComparison.OrdinalIgnoreCase))
{
var group = normalized.Substring("scheduler.".Length);
var schedulerList = component.Metadata.TryGetValue("capability.scheduler", out var listValue)
? listValue
: null;
return ContainsDelimitedValue(schedulerList, group);
}
if (normalized.Equals("scheduler", StringComparison.OrdinalIgnoreCase))
{
var schedulerList = component.Metadata.TryGetValue("capability.scheduler", out var listValue)
? listValue
: null;
return !string.IsNullOrWhiteSpace(schedulerList);
}
return false;
}
private bool IsDeclaredOnly()
{
return component.Metadata.TryGetValue("declaredOnly", out var value) && IsTruthy(value);
}
private string? GetSource()
{
return component.Metadata.TryGetValue("source", out var value) ? value : null;
}
private string? GetSourceKind()
{
var source = GetSource();
if (string.IsNullOrWhiteSpace(source))
{
return null;
}
source = source.Trim();
if (source.StartsWith("git:", StringComparison.OrdinalIgnoreCase))
{
return "git";
}
if (source.StartsWith("path:", StringComparison.OrdinalIgnoreCase))
{
return "path";
}
if (source.StartsWith("vendor-cache", StringComparison.OrdinalIgnoreCase))
{
return "vendor-cache";
}
if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| source.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return "registry";
}
return source;
}
private static ImmutableHashSet<string> ParseGroups(ImmutableDictionary<string, string> metadata)
{
if (!metadata.TryGetValue("groups", out var value) || string.IsNullOrWhiteSpace(value))
{
return ImmutableHashSet<string>.Empty;
}
var groups = value
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(static g => !string.IsNullOrWhiteSpace(g))
.Select(static g => g.Trim())
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
return groups;
}
private static bool ContainsDelimitedValue(string? delimited, string value)
{
if (string.IsNullOrWhiteSpace(delimited) || string.IsNullOrWhiteSpace(value))
{
return false;
}
return delimited
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(entry => entry.Equals(value, StringComparison.OrdinalIgnoreCase));
}
private static bool IsTruthy(string? value)
{
return value is not null
&& (value.Equals("true", StringComparison.OrdinalIgnoreCase)
|| value.Equals("1", StringComparison.OrdinalIgnoreCase)
|| value.Equals("yes", StringComparison.OrdinalIgnoreCase));
}
private static ImmutableHashSet<string> EvaluateAsStringSet(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
foreach (var argument in arguments)
{
var evaluated = evaluator.Evaluate(argument, scope).Raw;
switch (evaluated)
{
case ImmutableArray<object?> array:
foreach (var item in array)
{
if (item is string text && !string.IsNullOrWhiteSpace(text))
{
builder.Add(text.Trim());
}
}
break;
case string text when !string.IsNullOrWhiteSpace(text):
builder.Add(text.Trim());
break;
}
}
return builder.ToImmutable();
}
}
private sealed class VexScope
{
private readonly PolicyExpressionEvaluator evaluator;
private readonly PolicyEvaluationVexEvidence vex;
public VexScope(PolicyExpressionEvaluator evaluator, PolicyEvaluationVexEvidence vex)

View File

@@ -1,14 +1,15 @@
using System.IO;
using Microsoft.Extensions.Options;
using NetEscapades.Configuration.Yaml;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using System.IO;
using Microsoft.Extensions.Options;
using NetEscapades.Configuration.Yaml;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Policy.Engine.Hosting;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Endpoints;
using StellaOps.PolicyDsl;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.Streaming;
@@ -33,17 +34,17 @@ var policyEngineActivationConfigFiles = new[]
"policy-engine.activation.yaml",
"policy-engine.activation.local.yaml"
};
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
options.ConfigureBuilder = configurationBuilder =>
{
var contentRoot = builder.Environment.ContentRootPath;
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
options.ConfigureBuilder = configurationBuilder =>
{
var contentRoot = builder.Environment.ContentRootPath;
foreach (var relative in policyEngineConfigFiles)
{
var path = Path.Combine(contentRoot, relative);
@@ -59,12 +60,12 @@ builder.Configuration.AddStellaOpsDefaults(options =>
});
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
options.BindingSection = PolicyEngineOptions.SectionName;
options.ConfigureBuilder = configurationBuilder =>
{
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
options.BindingSection = PolicyEngineOptions.SectionName;
options.ConfigureBuilder = configurationBuilder =>
{
foreach (var relative in policyEngineConfigFiles)
{
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
@@ -79,35 +80,35 @@ var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(op
};
options.PostBind = static (value, _) => value.Validate();
});
builder.Configuration.AddConfiguration(bootstrap.Configuration);
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
builder.Services.AddOptions<PolicyEngineOptions>()
.Bind(builder.Configuration.GetSection(PolicyEngineOptions.SectionName))
.Validate(options =>
{
try
{
options.Validate();
return true;
}
catch (Exception ex)
{
throw new OptionsValidationException(
PolicyEngineOptions.SectionName,
typeof(PolicyEngineOptions),
new[] { ex.Message });
}
})
.ValidateOnStart();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
builder.Services.AddSingleton(TimeProvider.System);
.Bind(builder.Configuration.GetSection(PolicyEngineOptions.SectionName))
.Validate(options =>
{
try
{
options.Validate();
return true;
}
catch (Exception ex)
{
throw new OptionsValidationException(
PolicyEngineOptions.SectionName,
typeof(PolicyEngineOptions),
new[] { ex.Message });
}
})
.ValidateOnStart();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
builder.Services.AddSingleton<PolicyCompiler>();
builder.Services.AddSingleton<StellaOps.PolicyDsl.PolicyCompiler>();
builder.Services.AddSingleton<PolicyCompilationService>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
builder.Services.AddSingleton<PolicyEvaluationService>();
@@ -140,36 +141,36 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddStellaOpsScopeHandler();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: $"{PolicyEngineOptions.SectionName}:ResourceServer");
if (bootstrap.Options.Authority.Enabled)
{
builder.Services.AddStellaOpsAuthClient(clientOptions =>
{
clientOptions.Authority = bootstrap.Options.Authority.Issuer;
clientOptions.ClientId = bootstrap.Options.Authority.ClientId;
clientOptions.ClientSecret = bootstrap.Options.Authority.ClientSecret;
clientOptions.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.Authority.BackchannelTimeoutSeconds);
clientOptions.DefaultScopes.Clear();
foreach (var scope in bootstrap.Options.Authority.Scopes)
{
clientOptions.DefaultScopes.Add(scope);
}
});
}
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddStellaOpsScopeHandler();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: $"{PolicyEngineOptions.SectionName}:ResourceServer");
if (bootstrap.Options.Authority.Enabled)
{
builder.Services.AddStellaOpsAuthClient(clientOptions =>
{
clientOptions.Authority = bootstrap.Options.Authority.Issuer;
clientOptions.ClientId = bootstrap.Options.Authority.ClientId;
clientOptions.ClientSecret = bootstrap.Options.Authority.ClientSecret;
clientOptions.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.Authority.BackchannelTimeoutSeconds);
clientOptions.DefaultScopes.Clear();
foreach (var scope in bootstrap.Options.Authority.Scopes)
{
clientOptions.DefaultScopes.Add(scope);
}
});
}
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz");
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
diagnostics.IsReady

View File

@@ -4,22 +4,34 @@ using Microsoft.Extensions.Options;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Options;
namespace StellaOps.Policy.Engine.Services;
/// <summary>
/// Provides deterministic compilation for <c>stella-dsl@1</c> policy documents and exposes
/// basic statistics consumed by API/CLI surfaces.
/// </summary>
using StellaOps.PolicyDsl;
using DslCompiler = StellaOps.PolicyDsl.PolicyCompiler;
using DslCompilationResult = StellaOps.PolicyDsl.PolicyCompilationResult;
using IrDocument = StellaOps.PolicyDsl.PolicyIrDocument;
using IrAction = StellaOps.PolicyDsl.PolicyIrAction;
using IrAssignmentAction = StellaOps.PolicyDsl.PolicyIrAssignmentAction;
using IrAnnotateAction = StellaOps.PolicyDsl.PolicyIrAnnotateAction;
using IrIgnoreAction = StellaOps.PolicyDsl.PolicyIrIgnoreAction;
using IrEscalateAction = StellaOps.PolicyDsl.PolicyIrEscalateAction;
using IrRequireVexAction = StellaOps.PolicyDsl.PolicyIrRequireVexAction;
using IrWarnAction = StellaOps.PolicyDsl.PolicyIrWarnAction;
using IrDeferAction = StellaOps.PolicyDsl.PolicyIrDeferAction;
namespace StellaOps.Policy.Engine.Services;
/// <summary>
/// Provides deterministic compilation for <c>stella-dsl@1</c> policy documents and exposes
/// basic statistics consumed by API/CLI surfaces.
/// </summary>
internal sealed class PolicyCompilationService
{
private readonly PolicyCompiler compiler;
private readonly DslCompiler compiler;
private readonly PolicyComplexityAnalyzer complexityAnalyzer;
private readonly IOptionsMonitor<PolicyEngineOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
public PolicyCompilationService(
PolicyCompiler compiler,
DslCompiler compiler,
PolicyComplexityAnalyzer complexityAnalyzer,
IOptionsMonitor<PolicyEngineOptions> optionsMonitor,
TimeProvider timeProvider)
@@ -29,30 +41,30 @@ internal sealed class PolicyCompilationService
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public PolicyCompilationResultDto Compile(PolicyCompileRequest request)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (request.Dsl is null || string.IsNullOrWhiteSpace(request.Dsl.Source))
{
throw new ArgumentException("Compilation requires DSL source.", nameof(request));
}
if (!string.Equals(request.Dsl.Syntax, "stella-dsl@1", StringComparison.Ordinal))
{
public PolicyCompilationResultDto Compile(PolicyCompileRequest request)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (request.Dsl is null || string.IsNullOrWhiteSpace(request.Dsl.Source))
{
throw new ArgumentException("Compilation requires DSL source.", nameof(request));
}
if (!string.Equals(request.Dsl.Syntax, "stella-dsl@1", StringComparison.Ordinal))
{
return PolicyCompilationResultDto.FromFailure(
ImmutableArray.Create(PolicyIssue.Error(
PolicyDslDiagnosticCodes.UnsupportedSyntaxVersion,
DiagnosticCodes.UnsupportedSyntaxVersion,
$"Unsupported syntax '{request.Dsl.Syntax ?? "null"}'. Expected 'stella-dsl@1'.",
"dsl.syntax")),
complexity: null,
durationMilliseconds: 0);
}
var start = timeProvider.GetTimestamp();
var result = compiler.Compile(request.Dsl.Source);
var elapsed = timeProvider.GetElapsedTime(start, timeProvider.GetTimestamp());
@@ -95,11 +107,11 @@ internal sealed class PolicyCompilationService
? ImmutableArray.Create(diagnostic)
: diagnostics.Add(diagnostic);
}
internal sealed record PolicyCompileRequest(PolicyDslPayload Dsl);
internal sealed record PolicyDslPayload(string Syntax, string Source);
internal sealed record PolicyCompileRequest(PolicyDslPayload Dsl);
public sealed record PolicyDslPayload(string Syntax, string Source);
internal sealed record PolicyCompilationResultDto(
bool Success,
string? Digest,
@@ -116,7 +128,7 @@ internal sealed record PolicyCompilationResultDto(
new(false, null, null, ImmutableArray<byte>.Empty, diagnostics, complexity, durationMilliseconds);
public static PolicyCompilationResultDto FromSuccess(
PolicyCompilationResult compilationResult,
DslCompilationResult compilationResult,
PolicyComplexityReport complexity,
long durationMilliseconds)
{
@@ -136,45 +148,45 @@ internal sealed record PolicyCompilationResultDto(
durationMilliseconds);
}
}
internal sealed record PolicyCompilationStatistics(
int RuleCount,
ImmutableDictionary<string, int> ActionCounts)
{
public static PolicyCompilationStatistics Create(PolicyIrDocument document)
{
var actions = ImmutableDictionary.CreateBuilder<string, int>(StringComparer.OrdinalIgnoreCase);
void Increment(string key)
{
actions[key] = actions.TryGetValue(key, out var existing) ? existing + 1 : 1;
}
foreach (var rule in document.Rules)
{
foreach (var action in rule.ThenActions)
{
Increment(GetActionKey(action));
}
foreach (var action in rule.ElseActions)
{
Increment($"else:{GetActionKey(action)}");
}
}
return new PolicyCompilationStatistics(document.Rules.Length, actions.ToImmutable());
}
private static string GetActionKey(PolicyIrAction action) => action switch
{
PolicyIrAssignmentAction => "assign",
PolicyIrAnnotateAction => "annotate",
PolicyIrIgnoreAction => "ignore",
PolicyIrEscalateAction => "escalate",
PolicyIrRequireVexAction => "requireVex",
PolicyIrWarnAction => "warn",
PolicyIrDeferAction => "defer",
_ => "unknown"
};
}
internal sealed record PolicyCompilationStatistics(
int RuleCount,
ImmutableDictionary<string, int> ActionCounts)
{
public static PolicyCompilationStatistics Create(IrDocument document)
{
var actions = ImmutableDictionary.CreateBuilder<string, int>(StringComparer.OrdinalIgnoreCase);
void Increment(string key)
{
actions[key] = actions.TryGetValue(key, out var existing) ? existing + 1 : 1;
}
foreach (var rule in document.Rules)
{
foreach (var action in rule.ThenActions)
{
Increment(GetActionKey(action));
}
foreach (var action in rule.ElseActions)
{
Increment($"else:{GetActionKey(action)}");
}
}
return new PolicyCompilationStatistics(document.Rules.Length, actions.ToImmutable());
}
private static string GetActionKey(IrAction action) => action switch
{
IrAssignmentAction => "assign",
IrAnnotateAction => "annotate",
IrIgnoreAction => "ignore",
IrEscalateAction => "escalate",
IrRequireVexAction => "requireVex",
IrWarnAction => "warn",
IrDeferAction => "defer",
_ => "unknown"
};
}

View File

@@ -1,7 +1,7 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.PolicyDsl;
using StellaOps.Policy.Engine.Evaluation;
namespace StellaOps.Policy.Engine.Services;
@@ -23,19 +23,19 @@ internal sealed partial class PolicyEvaluationService
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
internal PolicyEvaluationResult Evaluate(PolicyIrDocument document, PolicyEvaluationContext context)
internal Evaluation.PolicyEvaluationResult Evaluate(PolicyIrDocument document, Evaluation.PolicyEvaluationContext context)
{
if (document is null)
{
throw new ArgumentNullException(nameof(document));
}
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
throw new ArgumentNullException(nameof(document));
}
var request = new PolicyEvaluationRequest(document, context);
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var request = new Evaluation.PolicyEvaluationRequest(document, context);
return evaluator.Evaluate(request);
}

View File

@@ -1,16 +1,17 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />

View File

@@ -1,19 +1,22 @@
namespace StellaOps.Policy.Engine.Compilation;
internal static class PolicyDslDiagnosticCodes
{
public const string UnexpectedCharacter = "POLICY-DSL-LEX-001";
public const string UnterminatedString = "POLICY-DSL-LEX-002";
public const string InvalidEscapeSequence = "POLICY-DSL-LEX-003";
public const string InvalidNumber = "POLICY-DSL-LEX-004";
public const string UnexpectedToken = "POLICY-DSL-PARSE-001";
public const string DuplicateSection = "POLICY-DSL-PARSE-002";
public const string MissingPolicyHeader = "POLICY-DSL-PARSE-003";
public const string UnsupportedSyntaxVersion = "POLICY-DSL-PARSE-004";
public const string DuplicateRuleName = "POLICY-DSL-PARSE-005";
public const string MissingBecauseClause = "POLICY-DSL-PARSE-006";
public const string MissingTerminator = "POLICY-DSL-PARSE-007";
public const string InvalidAction = "POLICY-DSL-PARSE-008";
public const string InvalidLiteral = "POLICY-DSL-PARSE-009";
public const string UnexpectedSection = "POLICY-DSL-PARSE-010";
}
namespace StellaOps.PolicyDsl;
/// <summary>
/// Diagnostic codes for policy DSL lexing and parsing errors.
/// </summary>
public static class DiagnosticCodes
{
public const string UnexpectedCharacter = "POLICY-DSL-LEX-001";
public const string UnterminatedString = "POLICY-DSL-LEX-002";
public const string InvalidEscapeSequence = "POLICY-DSL-LEX-003";
public const string InvalidNumber = "POLICY-DSL-LEX-004";
public const string UnexpectedToken = "POLICY-DSL-PARSE-001";
public const string DuplicateSection = "POLICY-DSL-PARSE-002";
public const string MissingPolicyHeader = "POLICY-DSL-PARSE-003";
public const string UnsupportedSyntaxVersion = "POLICY-DSL-PARSE-004";
public const string DuplicateRuleName = "POLICY-DSL-PARSE-005";
public const string MissingBecauseClause = "POLICY-DSL-PARSE-006";
public const string MissingTerminator = "POLICY-DSL-PARSE-007";
public const string InvalidAction = "POLICY-DSL-PARSE-008";
public const string InvalidLiteral = "POLICY-DSL-PARSE-009";
public const string UnexpectedSection = "POLICY-DSL-PARSE-010";
}

View File

@@ -0,0 +1,70 @@
namespace StellaOps.PolicyDsl;
/// <summary>
/// Represents the kind of token in the policy DSL.
/// </summary>
public enum TokenKind
{
EndOfFile = 0,
Identifier,
StringLiteral,
NumberLiteral,
BooleanLiteral,
LeftBrace,
RightBrace,
LeftParen,
RightParen,
LeftBracket,
RightBracket,
Comma,
Semicolon,
Colon,
Arrow, // =>
Assign, // =
Define, // :=
Dot,
KeywordPolicy,
KeywordSyntax,
KeywordMetadata,
KeywordProfile,
KeywordRule,
KeywordMap,
KeywordSource,
KeywordEnv,
KeywordIf,
KeywordThen,
KeywordWhen,
KeywordAnd,
KeywordOr,
KeywordNot,
KeywordPriority,
KeywordElse,
KeywordBecause,
KeywordSettings,
KeywordIgnore,
KeywordUntil,
KeywordEscalate,
KeywordTo,
KeywordRequireVex,
KeywordWarn,
KeywordMessage,
KeywordDefer,
KeywordAnnotate,
KeywordIn,
EqualEqual,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual,
Unknown,
}
/// <summary>
/// Represents a single token in the policy DSL.
/// </summary>
public readonly record struct DslToken(
TokenKind Kind,
string Text,
SourceSpan Span,
object? Value = null);

View File

@@ -1,169 +1,174 @@
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using StellaOps.Policy;
namespace StellaOps.Policy.Engine.Compilation;
public sealed class PolicyCompiler
{
public PolicyCompilationResult Compile(string source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
var parseResult = PolicyParser.Parse(source);
if (parseResult.Document is null)
{
return new PolicyCompilationResult(
Success: false,
Document: null,
Checksum: null,
CanonicalRepresentation: ImmutableArray<byte>.Empty,
Diagnostics: parseResult.Diagnostics);
}
if (parseResult.Diagnostics.Any(static issue => issue.Severity == PolicyIssueSeverity.Error))
{
return new PolicyCompilationResult(
Success: false,
Document: null,
Checksum: null,
CanonicalRepresentation: ImmutableArray<byte>.Empty,
Diagnostics: parseResult.Diagnostics);
}
var irDocument = BuildIntermediateRepresentation(parseResult.Document);
var canonical = PolicyIrSerializer.Serialize(irDocument);
var checksum = Convert.ToHexString(SHA256.HashData(canonical.AsSpan())).ToLowerInvariant();
return new PolicyCompilationResult(
Success: true,
Document: irDocument,
Checksum: checksum,
CanonicalRepresentation: canonical,
Diagnostics: parseResult.Diagnostics);
}
private static PolicyIrDocument BuildIntermediateRepresentation(PolicyDocumentNode node)
{
var metadata = node.Metadata
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => ToIrLiteral(kvp.Value), StringComparer.Ordinal);
var settings = node.Settings
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => ToIrLiteral(kvp.Value), StringComparer.Ordinal);
var profiles = ImmutableArray.CreateBuilder<PolicyIrProfile>(node.Profiles.Length);
foreach (var profile in node.Profiles)
{
var maps = ImmutableArray.CreateBuilder<PolicyIrProfileMap>();
var envs = ImmutableArray.CreateBuilder<PolicyIrProfileEnv>();
var scalars = ImmutableArray.CreateBuilder<PolicyIrProfileScalar>();
foreach (var item in profile.Items)
{
switch (item)
{
case PolicyProfileMapNode map:
maps.Add(new PolicyIrProfileMap(
map.Name,
map.Entries
.Select(entry => new PolicyIrProfileMapEntry(entry.Source, entry.Weight))
.ToImmutableArray()));
break;
case PolicyProfileEnvNode env:
envs.Add(new PolicyIrProfileEnv(
env.Name,
env.Entries
.Select(entry => new PolicyIrProfileEnvEntry(entry.Condition, entry.Weight))
.ToImmutableArray()));
break;
case PolicyProfileScalarNode scalar:
scalars.Add(new PolicyIrProfileScalar(scalar.Name, ToIrLiteral(scalar.Value)));
break;
}
}
profiles.Add(new PolicyIrProfile(
profile.Name,
maps.ToImmutable(),
envs.ToImmutable(),
scalars.ToImmutable()));
}
var rules = ImmutableArray.CreateBuilder<PolicyIrRule>(node.Rules.Length);
foreach (var rule in node.Rules)
{
var thenActions = ImmutableArray.CreateBuilder<PolicyIrAction>(rule.ThenActions.Length);
foreach (var action in rule.ThenActions)
{
var converted = ToIrAction(action);
if (converted is not null)
{
thenActions.Add(converted);
}
}
var elseActions = ImmutableArray.CreateBuilder<PolicyIrAction>(rule.ElseActions.Length);
foreach (var action in rule.ElseActions)
{
var converted = ToIrAction(action);
if (converted is not null)
{
elseActions.Add(converted);
}
}
rules.Add(new PolicyIrRule(
rule.Name,
rule.Priority,
rule.When,
thenActions.ToImmutable(),
elseActions.ToImmutable(),
rule.Because ?? string.Empty));
}
return new PolicyIrDocument(
node.Name,
node.Syntax,
metadata,
profiles.ToImmutable(),
settings,
rules.ToImmutable());
}
private static PolicyIrLiteral ToIrLiteral(PolicyLiteralValue value) => value switch
{
PolicyStringLiteral s => new PolicyIrStringLiteral(s.Value),
PolicyNumberLiteral n => new PolicyIrNumberLiteral(n.Value),
PolicyBooleanLiteral b => new PolicyIrBooleanLiteral(b.Value),
PolicyListLiteral list => new PolicyIrListLiteral(list.Items.Select(ToIrLiteral).ToImmutableArray()),
_ => new PolicyIrStringLiteral(string.Empty),
};
private static PolicyIrAction? ToIrAction(PolicyActionNode action) => action switch
{
PolicyAssignmentActionNode assign => new PolicyIrAssignmentAction(assign.Target.Segments, assign.Value),
PolicyAnnotateActionNode annotate => new PolicyIrAnnotateAction(annotate.Target.Segments, annotate.Value),
PolicyIgnoreActionNode ignore => new PolicyIrIgnoreAction(ignore.Until, ignore.Because),
PolicyEscalateActionNode escalate => new PolicyIrEscalateAction(escalate.To, escalate.When),
PolicyRequireVexActionNode require => new PolicyIrRequireVexAction(
require.Conditions
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal)),
PolicyWarnActionNode warn => new PolicyIrWarnAction(warn.Message),
PolicyDeferActionNode defer => new PolicyIrDeferAction(defer.Until),
_ => null,
};
}
public sealed record PolicyCompilationResult(
bool Success,
PolicyIrDocument? Document,
string? Checksum,
ImmutableArray<byte> CanonicalRepresentation,
ImmutableArray<PolicyIssue> Diagnostics);
using System.Collections.Immutable;
using System.Security.Cryptography;
using StellaOps.Policy;
namespace StellaOps.PolicyDsl;
/// <summary>
/// Compiles policy DSL source code into an intermediate representation.
/// </summary>
public sealed class PolicyCompiler
{
public PolicyCompilationResult Compile(string source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
var parseResult = PolicyParser.Parse(source);
if (parseResult.Document is null)
{
return new PolicyCompilationResult(
Success: false,
Document: null,
Checksum: null,
CanonicalRepresentation: ImmutableArray<byte>.Empty,
Diagnostics: parseResult.Diagnostics);
}
if (parseResult.Diagnostics.Any(static issue => issue.Severity == PolicyIssueSeverity.Error))
{
return new PolicyCompilationResult(
Success: false,
Document: null,
Checksum: null,
CanonicalRepresentation: ImmutableArray<byte>.Empty,
Diagnostics: parseResult.Diagnostics);
}
var irDocument = BuildIntermediateRepresentation(parseResult.Document);
var canonical = PolicyIrSerializer.Serialize(irDocument);
var checksum = Convert.ToHexString(SHA256.HashData(canonical.AsSpan())).ToLowerInvariant();
return new PolicyCompilationResult(
Success: true,
Document: irDocument,
Checksum: checksum,
CanonicalRepresentation: canonical,
Diagnostics: parseResult.Diagnostics);
}
private static PolicyIrDocument BuildIntermediateRepresentation(PolicyDocumentNode node)
{
var metadata = node.Metadata
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => ToIrLiteral(kvp.Value), StringComparer.Ordinal);
var settings = node.Settings
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => ToIrLiteral(kvp.Value), StringComparer.Ordinal);
var profiles = ImmutableArray.CreateBuilder<PolicyIrProfile>(node.Profiles.Length);
foreach (var profile in node.Profiles)
{
var maps = ImmutableArray.CreateBuilder<PolicyIrProfileMap>();
var envs = ImmutableArray.CreateBuilder<PolicyIrProfileEnv>();
var scalars = ImmutableArray.CreateBuilder<PolicyIrProfileScalar>();
foreach (var item in profile.Items)
{
switch (item)
{
case PolicyProfileMapNode map:
maps.Add(new PolicyIrProfileMap(
map.Name,
map.Entries
.Select(entry => new PolicyIrProfileMapEntry(entry.Source, entry.Weight))
.ToImmutableArray()));
break;
case PolicyProfileEnvNode env:
envs.Add(new PolicyIrProfileEnv(
env.Name,
env.Entries
.Select(entry => new PolicyIrProfileEnvEntry(entry.Condition, entry.Weight))
.ToImmutableArray()));
break;
case PolicyProfileScalarNode scalar:
scalars.Add(new PolicyIrProfileScalar(scalar.Name, ToIrLiteral(scalar.Value)));
break;
}
}
profiles.Add(new PolicyIrProfile(
profile.Name,
maps.ToImmutable(),
envs.ToImmutable(),
scalars.ToImmutable()));
}
var rules = ImmutableArray.CreateBuilder<PolicyIrRule>(node.Rules.Length);
foreach (var rule in node.Rules)
{
var thenActions = ImmutableArray.CreateBuilder<PolicyIrAction>(rule.ThenActions.Length);
foreach (var action in rule.ThenActions)
{
var converted = ToIrAction(action);
if (converted is not null)
{
thenActions.Add(converted);
}
}
var elseActions = ImmutableArray.CreateBuilder<PolicyIrAction>(rule.ElseActions.Length);
foreach (var action in rule.ElseActions)
{
var converted = ToIrAction(action);
if (converted is not null)
{
elseActions.Add(converted);
}
}
rules.Add(new PolicyIrRule(
rule.Name,
rule.Priority,
rule.When,
thenActions.ToImmutable(),
elseActions.ToImmutable(),
rule.Because ?? string.Empty));
}
return new PolicyIrDocument(
node.Name,
node.Syntax,
metadata,
profiles.ToImmutable(),
settings,
rules.ToImmutable());
}
private static PolicyIrLiteral ToIrLiteral(PolicyLiteralValue value) => value switch
{
PolicyStringLiteral s => new PolicyIrStringLiteral(s.Value),
PolicyNumberLiteral n => new PolicyIrNumberLiteral(n.Value),
PolicyBooleanLiteral b => new PolicyIrBooleanLiteral(b.Value),
PolicyListLiteral list => new PolicyIrListLiteral(list.Items.Select(ToIrLiteral).ToImmutableArray()),
_ => new PolicyIrStringLiteral(string.Empty),
};
private static PolicyIrAction? ToIrAction(PolicyActionNode action) => action switch
{
PolicyAssignmentActionNode assign => new PolicyIrAssignmentAction(assign.Target.Segments, assign.Value),
PolicyAnnotateActionNode annotate => new PolicyIrAnnotateAction(annotate.Target.Segments, annotate.Value),
PolicyIgnoreActionNode ignore => new PolicyIrIgnoreAction(ignore.Until, ignore.Because),
PolicyEscalateActionNode escalate => new PolicyIrEscalateAction(escalate.To, escalate.When),
PolicyRequireVexActionNode require => new PolicyIrRequireVexAction(
require.Conditions
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal)),
PolicyWarnActionNode warn => new PolicyIrWarnAction(warn.Message),
PolicyDeferActionNode defer => new PolicyIrDeferAction(defer.Until),
_ => null,
};
}
/// <summary>
/// Result of compiling a policy DSL source.
/// </summary>
public sealed record PolicyCompilationResult(
bool Success,
PolicyIrDocument? Document,
string? Checksum,
ImmutableArray<byte> CanonicalRepresentation,
ImmutableArray<PolicyIssue> Diagnostics);

View File

@@ -0,0 +1,213 @@
namespace StellaOps.PolicyDsl;
/// <summary>
/// Factory for creating policy evaluation engines from compiled policy documents.
/// </summary>
public sealed class PolicyEngineFactory
{
private readonly PolicyCompiler _compiler = new();
/// <summary>
/// Creates a policy engine from source code.
/// </summary>
/// <param name="source">The policy DSL source code.</param>
/// <returns>A policy engine if compilation succeeds, otherwise null with diagnostics.</returns>
public PolicyEngineResult CreateFromSource(string source)
{
var compilation = _compiler.Compile(source);
if (!compilation.Success || compilation.Document is null)
{
return new PolicyEngineResult(null, compilation.Diagnostics);
}
var engine = new PolicyEngine(compilation.Document, compilation.Checksum!);
return new PolicyEngineResult(engine, compilation.Diagnostics);
}
/// <summary>
/// Creates a policy engine from a pre-compiled IR document.
/// </summary>
/// <param name="document">The compiled policy IR document.</param>
/// <param name="checksum">The policy checksum.</param>
/// <returns>A policy engine.</returns>
public PolicyEngine CreateFromDocument(PolicyIrDocument document, string checksum)
{
return new PolicyEngine(document, checksum);
}
}
/// <summary>
/// Result of creating a policy engine.
/// </summary>
public sealed record PolicyEngineResult(
PolicyEngine? Engine,
System.Collections.Immutable.ImmutableArray<StellaOps.Policy.PolicyIssue> Diagnostics);
/// <summary>
/// A lightweight policy evaluation engine.
/// </summary>
public sealed class PolicyEngine
{
internal PolicyEngine(PolicyIrDocument document, string checksum)
{
Document = document;
Checksum = checksum;
}
/// <summary>
/// Gets the compiled policy document.
/// </summary>
public PolicyIrDocument Document { get; }
/// <summary>
/// Gets the policy checksum (SHA-256 of canonical representation).
/// </summary>
public string Checksum { get; }
/// <summary>
/// Gets the policy name.
/// </summary>
public string Name => Document.Name;
/// <summary>
/// Gets the policy syntax version.
/// </summary>
public string Syntax => Document.Syntax;
/// <summary>
/// Gets the number of rules in the policy.
/// </summary>
public int RuleCount => Document.Rules.Length;
/// <summary>
/// Evaluates the policy against the given signal context.
/// </summary>
/// <param name="context">The signal context to evaluate against.</param>
/// <returns>The evaluation result.</returns>
public PolicyEvaluationResult Evaluate(SignalContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var matchedRules = new List<string>();
var actions = new List<EvaluatedAction>();
foreach (var rule in Document.Rules.OrderByDescending(r => r.Priority))
{
var matched = EvaluateExpression(rule.When, context);
if (matched)
{
matchedRules.Add(rule.Name);
foreach (var action in rule.ThenActions)
{
actions.Add(new EvaluatedAction(rule.Name, action, WasElseBranch: false));
}
}
else
{
foreach (var action in rule.ElseActions)
{
actions.Add(new EvaluatedAction(rule.Name, action, WasElseBranch: true));
}
}
}
return new PolicyEvaluationResult(
PolicyName: Name,
PolicyChecksum: Checksum,
MatchedRules: matchedRules.ToArray(),
Actions: actions.ToArray());
}
private static bool EvaluateExpression(PolicyExpression expression, SignalContext context)
{
return expression switch
{
PolicyBinaryExpression binary => EvaluateBinary(binary, context),
PolicyUnaryExpression unary => EvaluateUnary(unary, context),
PolicyLiteralExpression literal => literal.Value is bool b && b,
PolicyIdentifierExpression identifier => context.HasSignal(identifier.Name),
PolicyMemberAccessExpression member => EvaluateMemberAccess(member, context),
_ => false,
};
}
private static bool EvaluateBinary(PolicyBinaryExpression binary, SignalContext context)
{
return binary.Operator switch
{
PolicyBinaryOperator.And => EvaluateExpression(binary.Left, context) && EvaluateExpression(binary.Right, context),
PolicyBinaryOperator.Or => EvaluateExpression(binary.Left, context) || EvaluateExpression(binary.Right, context),
PolicyBinaryOperator.Equal => EvaluateEquality(binary.Left, binary.Right, context, negate: false),
PolicyBinaryOperator.NotEqual => EvaluateEquality(binary.Left, binary.Right, context, negate: true),
_ => false,
};
}
private static bool EvaluateUnary(PolicyUnaryExpression unary, SignalContext context)
{
return unary.Operator switch
{
PolicyUnaryOperator.Not => !EvaluateExpression(unary.Operand, context),
_ => false,
};
}
private static bool EvaluateMemberAccess(PolicyMemberAccessExpression member, SignalContext context)
{
var value = ResolveValue(member.Target, context);
if (value is IDictionary<string, object?> dict)
{
return dict.TryGetValue(member.Member, out var v) && v is bool b && b;
}
return false;
}
private static bool EvaluateEquality(PolicyExpression left, PolicyExpression right, SignalContext context, bool negate)
{
var leftValue = ResolveValue(left, context);
var rightValue = ResolveValue(right, context);
var equal = Equals(leftValue, rightValue);
return negate ? !equal : equal;
}
private static object? ResolveValue(PolicyExpression expression, SignalContext context)
{
return expression switch
{
PolicyLiteralExpression literal => literal.Value,
PolicyIdentifierExpression identifier => context.GetSignal(identifier.Name),
PolicyMemberAccessExpression member => ResolveMemberValue(member, context),
_ => null,
};
}
private static object? ResolveMemberValue(PolicyMemberAccessExpression member, SignalContext context)
{
var target = ResolveValue(member.Target, context);
if (target is IDictionary<string, object?> dict)
{
return dict.TryGetValue(member.Member, out var v) ? v : null;
}
return null;
}
}
/// <summary>
/// Result of evaluating a policy.
/// </summary>
public sealed record PolicyEvaluationResult(
string PolicyName,
string PolicyChecksum,
string[] MatchedRules,
EvaluatedAction[] Actions);
/// <summary>
/// An action that was evaluated as part of policy execution.
/// </summary>
public sealed record EvaluatedAction(
string RuleName,
PolicyIrAction Action,
bool WasElseBranch);

View File

@@ -1,61 +1,64 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Engine.Compilation;
public sealed record PolicyIrDocument(
string Name,
string Syntax,
ImmutableSortedDictionary<string, PolicyIrLiteral> Metadata,
ImmutableArray<PolicyIrProfile> Profiles,
ImmutableSortedDictionary<string, PolicyIrLiteral> Settings,
ImmutableArray<PolicyIrRule> Rules);
public abstract record PolicyIrLiteral;
public sealed record PolicyIrStringLiteral(string Value) : PolicyIrLiteral;
public sealed record PolicyIrNumberLiteral(decimal Value) : PolicyIrLiteral;
public sealed record PolicyIrBooleanLiteral(bool Value) : PolicyIrLiteral;
public sealed record PolicyIrListLiteral(ImmutableArray<PolicyIrLiteral> Items) : PolicyIrLiteral;
public sealed record PolicyIrProfile(
string Name,
ImmutableArray<PolicyIrProfileMap> Maps,
ImmutableArray<PolicyIrProfileEnv> Environments,
ImmutableArray<PolicyIrProfileScalar> Scalars);
public sealed record PolicyIrProfileMap(string Name, ImmutableArray<PolicyIrProfileMapEntry> Entries);
public sealed record PolicyIrProfileMapEntry(string Source, decimal Weight);
public sealed record PolicyIrProfileEnv(string Name, ImmutableArray<PolicyIrProfileEnvEntry> Entries);
public sealed record PolicyIrProfileEnvEntry(PolicyExpression Condition, decimal Weight);
public sealed record PolicyIrProfileScalar(string Name, PolicyIrLiteral Value);
public sealed record PolicyIrRule(
string Name,
int Priority,
PolicyExpression When,
ImmutableArray<PolicyIrAction> ThenActions,
ImmutableArray<PolicyIrAction> ElseActions,
string Because);
public abstract record PolicyIrAction;
public sealed record PolicyIrAssignmentAction(ImmutableArray<string> Target, PolicyExpression Value) : PolicyIrAction;
public sealed record PolicyIrAnnotateAction(ImmutableArray<string> Target, PolicyExpression Value) : PolicyIrAction;
public sealed record PolicyIrIgnoreAction(PolicyExpression? Until, string? Because) : PolicyIrAction;
public sealed record PolicyIrEscalateAction(PolicyExpression? To, PolicyExpression? When) : PolicyIrAction;
public sealed record PolicyIrRequireVexAction(ImmutableSortedDictionary<string, PolicyExpression> Conditions) : PolicyIrAction;
public sealed record PolicyIrWarnAction(PolicyExpression? Message) : PolicyIrAction;
public sealed record PolicyIrDeferAction(PolicyExpression? Until) : PolicyIrAction;
using System.Collections.Immutable;
namespace StellaOps.PolicyDsl;
/// <summary>
/// Intermediate representation of a compiled policy document.
/// </summary>
public sealed record PolicyIrDocument(
string Name,
string Syntax,
ImmutableSortedDictionary<string, PolicyIrLiteral> Metadata,
ImmutableArray<PolicyIrProfile> Profiles,
ImmutableSortedDictionary<string, PolicyIrLiteral> Settings,
ImmutableArray<PolicyIrRule> Rules);
public abstract record PolicyIrLiteral;
public sealed record PolicyIrStringLiteral(string Value) : PolicyIrLiteral;
public sealed record PolicyIrNumberLiteral(decimal Value) : PolicyIrLiteral;
public sealed record PolicyIrBooleanLiteral(bool Value) : PolicyIrLiteral;
public sealed record PolicyIrListLiteral(ImmutableArray<PolicyIrLiteral> Items) : PolicyIrLiteral;
public sealed record PolicyIrProfile(
string Name,
ImmutableArray<PolicyIrProfileMap> Maps,
ImmutableArray<PolicyIrProfileEnv> Environments,
ImmutableArray<PolicyIrProfileScalar> Scalars);
public sealed record PolicyIrProfileMap(string Name, ImmutableArray<PolicyIrProfileMapEntry> Entries);
public sealed record PolicyIrProfileMapEntry(string Source, decimal Weight);
public sealed record PolicyIrProfileEnv(string Name, ImmutableArray<PolicyIrProfileEnvEntry> Entries);
public sealed record PolicyIrProfileEnvEntry(PolicyExpression Condition, decimal Weight);
public sealed record PolicyIrProfileScalar(string Name, PolicyIrLiteral Value);
public sealed record PolicyIrRule(
string Name,
int Priority,
PolicyExpression When,
ImmutableArray<PolicyIrAction> ThenActions,
ImmutableArray<PolicyIrAction> ElseActions,
string Because);
public abstract record PolicyIrAction;
public sealed record PolicyIrAssignmentAction(ImmutableArray<string> Target, PolicyExpression Value) : PolicyIrAction;
public sealed record PolicyIrAnnotateAction(ImmutableArray<string> Target, PolicyExpression Value) : PolicyIrAction;
public sealed record PolicyIrIgnoreAction(PolicyExpression? Until, string? Because) : PolicyIrAction;
public sealed record PolicyIrEscalateAction(PolicyExpression? To, PolicyExpression? When) : PolicyIrAction;
public sealed record PolicyIrRequireVexAction(ImmutableSortedDictionary<string, PolicyExpression> Conditions) : PolicyIrAction;
public sealed record PolicyIrWarnAction(PolicyExpression? Message) : PolicyIrAction;
public sealed record PolicyIrDeferAction(PolicyExpression? Until) : PolicyIrAction;

View File

@@ -1,415 +1,418 @@
using System.Buffers;
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.Policy.Engine.Compilation;
internal static class PolicyIrSerializer
{
public static ImmutableArray<byte> Serialize(PolicyIrDocument document)
{
var buffer = new ArrayBufferWriter<byte>();
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
Indented = false,
SkipValidation = false
});
WriteDocument(writer, document);
writer.Flush();
return buffer.WrittenSpan.ToArray().ToImmutableArray();
}
private static void WriteDocument(Utf8JsonWriter writer, PolicyIrDocument document)
{
writer.WriteStartObject();
writer.WriteString("name", document.Name);
writer.WriteString("syntax", document.Syntax);
writer.WritePropertyName("metadata");
WriteLiteralDictionary(writer, document.Metadata);
writer.WritePropertyName("profiles");
writer.WriteStartArray();
foreach (var profile in document.Profiles)
{
WriteProfile(writer, profile);
}
writer.WriteEndArray();
writer.WritePropertyName("settings");
WriteLiteralDictionary(writer, document.Settings);
writer.WritePropertyName("rules");
writer.WriteStartArray();
foreach (var rule in document.Rules)
{
WriteRule(writer, rule);
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static void WriteProfile(Utf8JsonWriter writer, PolicyIrProfile profile)
{
writer.WriteStartObject();
writer.WriteString("name", profile.Name);
writer.WritePropertyName("maps");
writer.WriteStartArray();
foreach (var map in profile.Maps)
{
writer.WriteStartObject();
writer.WriteString("name", map.Name);
writer.WritePropertyName("entries");
writer.WriteStartArray();
foreach (var entry in map.Entries)
{
writer.WriteStartObject();
writer.WriteString("source", entry.Source);
writer.WriteNumber("weight", entry.Weight);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("env");
writer.WriteStartArray();
foreach (var env in profile.Environments)
{
writer.WriteStartObject();
writer.WriteString("name", env.Name);
writer.WritePropertyName("entries");
writer.WriteStartArray();
foreach (var entry in env.Entries)
{
writer.WriteStartObject();
writer.WritePropertyName("condition");
WriteExpression(writer, entry.Condition);
writer.WriteNumber("weight", entry.Weight);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("scalars");
writer.WriteStartArray();
foreach (var scalar in profile.Scalars)
{
writer.WriteStartObject();
writer.WriteString("name", scalar.Name);
writer.WritePropertyName("value");
WriteLiteral(writer, scalar.Value);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static void WriteRule(Utf8JsonWriter writer, PolicyIrRule rule)
{
writer.WriteStartObject();
writer.WriteString("name", rule.Name);
writer.WriteNumber("priority", rule.Priority);
writer.WritePropertyName("when");
WriteExpression(writer, rule.When);
writer.WritePropertyName("then");
WriteActions(writer, rule.ThenActions);
writer.WritePropertyName("else");
WriteActions(writer, rule.ElseActions);
writer.WriteString("because", rule.Because);
writer.WriteEndObject();
}
private static void WriteActions(Utf8JsonWriter writer, ImmutableArray<PolicyIrAction> actions)
{
writer.WriteStartArray();
foreach (var action in actions)
{
WriteAction(writer, action);
}
writer.WriteEndArray();
}
private static void WriteAction(Utf8JsonWriter writer, PolicyIrAction action)
{
switch (action)
{
case PolicyIrAssignmentAction assign:
writer.WriteStartObject();
writer.WriteString("type", "assign");
WriteReference(writer, assign.Target);
writer.WritePropertyName("value");
WriteExpression(writer, assign.Value);
writer.WriteEndObject();
break;
case PolicyIrAnnotateAction annotate:
writer.WriteStartObject();
writer.WriteString("type", "annotate");
WriteReference(writer, annotate.Target);
writer.WritePropertyName("value");
WriteExpression(writer, annotate.Value);
writer.WriteEndObject();
break;
case PolicyIrIgnoreAction ignore:
writer.WriteStartObject();
writer.WriteString("type", "ignore");
writer.WritePropertyName("until");
WriteOptionalExpression(writer, ignore.Until);
writer.WriteString("because", ignore.Because ?? string.Empty);
writer.WriteEndObject();
break;
case PolicyIrEscalateAction escalate:
writer.WriteStartObject();
writer.WriteString("type", "escalate");
writer.WritePropertyName("to");
WriteOptionalExpression(writer, escalate.To);
writer.WritePropertyName("when");
WriteOptionalExpression(writer, escalate.When);
writer.WriteEndObject();
break;
case PolicyIrRequireVexAction require:
writer.WriteStartObject();
writer.WriteString("type", "requireVex");
writer.WritePropertyName("conditions");
writer.WriteStartObject();
foreach (var kvp in require.Conditions)
{
writer.WritePropertyName(kvp.Key);
WriteExpression(writer, kvp.Value);
}
writer.WriteEndObject();
writer.WriteEndObject();
break;
case PolicyIrWarnAction warn:
writer.WriteStartObject();
writer.WriteString("type", "warn");
writer.WritePropertyName("message");
WriteOptionalExpression(writer, warn.Message);
writer.WriteEndObject();
break;
case PolicyIrDeferAction defer:
writer.WriteStartObject();
writer.WriteString("type", "defer");
writer.WritePropertyName("until");
WriteOptionalExpression(writer, defer.Until);
writer.WriteEndObject();
break;
}
}
private static void WriteReference(Utf8JsonWriter writer, ImmutableArray<string> segments)
{
writer.WritePropertyName("target");
writer.WriteStartArray();
foreach (var segment in segments)
{
writer.WriteStringValue(segment);
}
writer.WriteEndArray();
}
private static void WriteOptionalExpression(Utf8JsonWriter writer, PolicyExpression? expression)
{
if (expression is null)
{
writer.WriteNullValue();
return;
}
WriteExpression(writer, expression);
}
private static void WriteExpression(Utf8JsonWriter writer, PolicyExpression expression)
{
switch (expression)
{
case PolicyLiteralExpression literal:
writer.WriteStartObject();
writer.WriteString("type", "literal");
writer.WritePropertyName("value");
WriteLiteralValue(writer, literal.Value);
writer.WriteEndObject();
break;
case PolicyListExpression list:
writer.WriteStartObject();
writer.WriteString("type", "list");
writer.WritePropertyName("items");
writer.WriteStartArray();
foreach (var item in list.Items)
{
WriteExpression(writer, item);
}
writer.WriteEndArray();
writer.WriteEndObject();
break;
case PolicyIdentifierExpression identifier:
writer.WriteStartObject();
writer.WriteString("type", "identifier");
writer.WriteString("name", identifier.Name);
writer.WriteEndObject();
break;
case PolicyMemberAccessExpression member:
writer.WriteStartObject();
writer.WriteString("type", "member");
writer.WritePropertyName("target");
WriteExpression(writer, member.Target);
writer.WriteString("member", member.Member);
writer.WriteEndObject();
break;
case PolicyInvocationExpression invocation:
writer.WriteStartObject();
writer.WriteString("type", "call");
writer.WritePropertyName("target");
WriteExpression(writer, invocation.Target);
writer.WritePropertyName("args");
writer.WriteStartArray();
foreach (var arg in invocation.Arguments)
{
WriteExpression(writer, arg);
}
writer.WriteEndArray();
writer.WriteEndObject();
break;
case PolicyIndexerExpression indexer:
writer.WriteStartObject();
writer.WriteString("type", "indexer");
writer.WritePropertyName("target");
WriteExpression(writer, indexer.Target);
writer.WritePropertyName("index");
WriteExpression(writer, indexer.Index);
writer.WriteEndObject();
break;
case PolicyUnaryExpression unary:
writer.WriteStartObject();
writer.WriteString("type", "unary");
writer.WriteString("op", unary.Operator switch
{
PolicyUnaryOperator.Not => "not",
_ => unary.Operator.ToString().ToLowerInvariant(),
});
writer.WritePropertyName("operand");
WriteExpression(writer, unary.Operand);
writer.WriteEndObject();
break;
case PolicyBinaryExpression binary:
writer.WriteStartObject();
writer.WriteString("type", "binary");
writer.WriteString("op", GetBinaryOperator(binary.Operator));
writer.WritePropertyName("left");
WriteExpression(writer, binary.Left);
writer.WritePropertyName("right");
WriteExpression(writer, binary.Right);
writer.WriteEndObject();
break;
default:
writer.WriteStartObject();
writer.WriteString("type", "unknown");
writer.WriteEndObject();
break;
}
}
private static string GetBinaryOperator(PolicyBinaryOperator op) => op switch
{
PolicyBinaryOperator.And => "and",
PolicyBinaryOperator.Or => "or",
PolicyBinaryOperator.Equal => "eq",
PolicyBinaryOperator.NotEqual => "neq",
PolicyBinaryOperator.LessThan => "lt",
PolicyBinaryOperator.LessThanOrEqual => "lte",
PolicyBinaryOperator.GreaterThan => "gt",
PolicyBinaryOperator.GreaterThanOrEqual => "gte",
PolicyBinaryOperator.In => "in",
PolicyBinaryOperator.NotIn => "not_in",
_ => op.ToString().ToLowerInvariant(),
};
private static void WriteLiteralDictionary(Utf8JsonWriter writer, ImmutableSortedDictionary<string, PolicyIrLiteral> dictionary)
{
writer.WriteStartObject();
foreach (var kvp in dictionary)
{
writer.WritePropertyName(kvp.Key);
WriteLiteral(writer, kvp.Value);
}
writer.WriteEndObject();
}
private static void WriteLiteral(Utf8JsonWriter writer, PolicyIrLiteral literal)
{
switch (literal)
{
case PolicyIrStringLiteral s:
writer.WriteStringValue(s.Value);
break;
case PolicyIrNumberLiteral n:
writer.WriteNumberValue(n.Value);
break;
case PolicyIrBooleanLiteral b:
writer.WriteBooleanValue(b.Value);
break;
case PolicyIrListLiteral list:
writer.WriteStartArray();
foreach (var item in list.Items)
{
WriteLiteral(writer, item);
}
writer.WriteEndArray();
break;
default:
writer.WriteNullValue();
break;
}
}
private static void WriteLiteralValue(Utf8JsonWriter writer, object? value)
{
switch (value)
{
case null:
writer.WriteNullValue();
break;
case string s:
writer.WriteStringValue(s);
break;
case bool b:
writer.WriteBooleanValue(b);
break;
case decimal dec:
writer.WriteNumberValue(dec);
break;
case double dbl:
writer.WriteNumberValue(dbl);
break;
case int i:
writer.WriteNumberValue(i);
break;
default:
writer.WriteStringValue(value.ToString());
break;
}
}
}
using System.Buffers;
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.PolicyDsl;
/// <summary>
/// Serializes policy IR documents to a canonical JSON representation for hashing.
/// </summary>
public static class PolicyIrSerializer
{
public static ImmutableArray<byte> Serialize(PolicyIrDocument document)
{
var buffer = new ArrayBufferWriter<byte>();
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
Indented = false,
SkipValidation = false
});
WriteDocument(writer, document);
writer.Flush();
return buffer.WrittenSpan.ToArray().ToImmutableArray();
}
private static void WriteDocument(Utf8JsonWriter writer, PolicyIrDocument document)
{
writer.WriteStartObject();
writer.WriteString("name", document.Name);
writer.WriteString("syntax", document.Syntax);
writer.WritePropertyName("metadata");
WriteLiteralDictionary(writer, document.Metadata);
writer.WritePropertyName("profiles");
writer.WriteStartArray();
foreach (var profile in document.Profiles)
{
WriteProfile(writer, profile);
}
writer.WriteEndArray();
writer.WritePropertyName("settings");
WriteLiteralDictionary(writer, document.Settings);
writer.WritePropertyName("rules");
writer.WriteStartArray();
foreach (var rule in document.Rules)
{
WriteRule(writer, rule);
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static void WriteProfile(Utf8JsonWriter writer, PolicyIrProfile profile)
{
writer.WriteStartObject();
writer.WriteString("name", profile.Name);
writer.WritePropertyName("maps");
writer.WriteStartArray();
foreach (var map in profile.Maps)
{
writer.WriteStartObject();
writer.WriteString("name", map.Name);
writer.WritePropertyName("entries");
writer.WriteStartArray();
foreach (var entry in map.Entries)
{
writer.WriteStartObject();
writer.WriteString("source", entry.Source);
writer.WriteNumber("weight", entry.Weight);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("env");
writer.WriteStartArray();
foreach (var env in profile.Environments)
{
writer.WriteStartObject();
writer.WriteString("name", env.Name);
writer.WritePropertyName("entries");
writer.WriteStartArray();
foreach (var entry in env.Entries)
{
writer.WriteStartObject();
writer.WritePropertyName("condition");
WriteExpression(writer, entry.Condition);
writer.WriteNumber("weight", entry.Weight);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("scalars");
writer.WriteStartArray();
foreach (var scalar in profile.Scalars)
{
writer.WriteStartObject();
writer.WriteString("name", scalar.Name);
writer.WritePropertyName("value");
WriteLiteral(writer, scalar.Value);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static void WriteRule(Utf8JsonWriter writer, PolicyIrRule rule)
{
writer.WriteStartObject();
writer.WriteString("name", rule.Name);
writer.WriteNumber("priority", rule.Priority);
writer.WritePropertyName("when");
WriteExpression(writer, rule.When);
writer.WritePropertyName("then");
WriteActions(writer, rule.ThenActions);
writer.WritePropertyName("else");
WriteActions(writer, rule.ElseActions);
writer.WriteString("because", rule.Because);
writer.WriteEndObject();
}
private static void WriteActions(Utf8JsonWriter writer, ImmutableArray<PolicyIrAction> actions)
{
writer.WriteStartArray();
foreach (var action in actions)
{
WriteAction(writer, action);
}
writer.WriteEndArray();
}
private static void WriteAction(Utf8JsonWriter writer, PolicyIrAction action)
{
switch (action)
{
case PolicyIrAssignmentAction assign:
writer.WriteStartObject();
writer.WriteString("type", "assign");
WriteReference(writer, assign.Target);
writer.WritePropertyName("value");
WriteExpression(writer, assign.Value);
writer.WriteEndObject();
break;
case PolicyIrAnnotateAction annotate:
writer.WriteStartObject();
writer.WriteString("type", "annotate");
WriteReference(writer, annotate.Target);
writer.WritePropertyName("value");
WriteExpression(writer, annotate.Value);
writer.WriteEndObject();
break;
case PolicyIrIgnoreAction ignore:
writer.WriteStartObject();
writer.WriteString("type", "ignore");
writer.WritePropertyName("until");
WriteOptionalExpression(writer, ignore.Until);
writer.WriteString("because", ignore.Because ?? string.Empty);
writer.WriteEndObject();
break;
case PolicyIrEscalateAction escalate:
writer.WriteStartObject();
writer.WriteString("type", "escalate");
writer.WritePropertyName("to");
WriteOptionalExpression(writer, escalate.To);
writer.WritePropertyName("when");
WriteOptionalExpression(writer, escalate.When);
writer.WriteEndObject();
break;
case PolicyIrRequireVexAction require:
writer.WriteStartObject();
writer.WriteString("type", "requireVex");
writer.WritePropertyName("conditions");
writer.WriteStartObject();
foreach (var kvp in require.Conditions)
{
writer.WritePropertyName(kvp.Key);
WriteExpression(writer, kvp.Value);
}
writer.WriteEndObject();
writer.WriteEndObject();
break;
case PolicyIrWarnAction warn:
writer.WriteStartObject();
writer.WriteString("type", "warn");
writer.WritePropertyName("message");
WriteOptionalExpression(writer, warn.Message);
writer.WriteEndObject();
break;
case PolicyIrDeferAction defer:
writer.WriteStartObject();
writer.WriteString("type", "defer");
writer.WritePropertyName("until");
WriteOptionalExpression(writer, defer.Until);
writer.WriteEndObject();
break;
}
}
private static void WriteReference(Utf8JsonWriter writer, ImmutableArray<string> segments)
{
writer.WritePropertyName("target");
writer.WriteStartArray();
foreach (var segment in segments)
{
writer.WriteStringValue(segment);
}
writer.WriteEndArray();
}
private static void WriteOptionalExpression(Utf8JsonWriter writer, PolicyExpression? expression)
{
if (expression is null)
{
writer.WriteNullValue();
return;
}
WriteExpression(writer, expression);
}
private static void WriteExpression(Utf8JsonWriter writer, PolicyExpression expression)
{
switch (expression)
{
case PolicyLiteralExpression literal:
writer.WriteStartObject();
writer.WriteString("type", "literal");
writer.WritePropertyName("value");
WriteLiteralValue(writer, literal.Value);
writer.WriteEndObject();
break;
case PolicyListExpression list:
writer.WriteStartObject();
writer.WriteString("type", "list");
writer.WritePropertyName("items");
writer.WriteStartArray();
foreach (var item in list.Items)
{
WriteExpression(writer, item);
}
writer.WriteEndArray();
writer.WriteEndObject();
break;
case PolicyIdentifierExpression identifier:
writer.WriteStartObject();
writer.WriteString("type", "identifier");
writer.WriteString("name", identifier.Name);
writer.WriteEndObject();
break;
case PolicyMemberAccessExpression member:
writer.WriteStartObject();
writer.WriteString("type", "member");
writer.WritePropertyName("target");
WriteExpression(writer, member.Target);
writer.WriteString("member", member.Member);
writer.WriteEndObject();
break;
case PolicyInvocationExpression invocation:
writer.WriteStartObject();
writer.WriteString("type", "call");
writer.WritePropertyName("target");
WriteExpression(writer, invocation.Target);
writer.WritePropertyName("args");
writer.WriteStartArray();
foreach (var arg in invocation.Arguments)
{
WriteExpression(writer, arg);
}
writer.WriteEndArray();
writer.WriteEndObject();
break;
case PolicyIndexerExpression indexer:
writer.WriteStartObject();
writer.WriteString("type", "indexer");
writer.WritePropertyName("target");
WriteExpression(writer, indexer.Target);
writer.WritePropertyName("index");
WriteExpression(writer, indexer.Index);
writer.WriteEndObject();
break;
case PolicyUnaryExpression unary:
writer.WriteStartObject();
writer.WriteString("type", "unary");
writer.WriteString("op", unary.Operator switch
{
PolicyUnaryOperator.Not => "not",
_ => unary.Operator.ToString().ToLowerInvariant(),
});
writer.WritePropertyName("operand");
WriteExpression(writer, unary.Operand);
writer.WriteEndObject();
break;
case PolicyBinaryExpression binary:
writer.WriteStartObject();
writer.WriteString("type", "binary");
writer.WriteString("op", GetBinaryOperator(binary.Operator));
writer.WritePropertyName("left");
WriteExpression(writer, binary.Left);
writer.WritePropertyName("right");
WriteExpression(writer, binary.Right);
writer.WriteEndObject();
break;
default:
writer.WriteStartObject();
writer.WriteString("type", "unknown");
writer.WriteEndObject();
break;
}
}
private static string GetBinaryOperator(PolicyBinaryOperator op) => op switch
{
PolicyBinaryOperator.And => "and",
PolicyBinaryOperator.Or => "or",
PolicyBinaryOperator.Equal => "eq",
PolicyBinaryOperator.NotEqual => "neq",
PolicyBinaryOperator.LessThan => "lt",
PolicyBinaryOperator.LessThanOrEqual => "lte",
PolicyBinaryOperator.GreaterThan => "gt",
PolicyBinaryOperator.GreaterThanOrEqual => "gte",
PolicyBinaryOperator.In => "in",
PolicyBinaryOperator.NotIn => "not_in",
_ => op.ToString().ToLowerInvariant(),
};
private static void WriteLiteralDictionary(Utf8JsonWriter writer, ImmutableSortedDictionary<string, PolicyIrLiteral> dictionary)
{
writer.WriteStartObject();
foreach (var kvp in dictionary)
{
writer.WritePropertyName(kvp.Key);
WriteLiteral(writer, kvp.Value);
}
writer.WriteEndObject();
}
private static void WriteLiteral(Utf8JsonWriter writer, PolicyIrLiteral literal)
{
switch (literal)
{
case PolicyIrStringLiteral s:
writer.WriteStringValue(s.Value);
break;
case PolicyIrNumberLiteral n:
writer.WriteNumberValue(n.Value);
break;
case PolicyIrBooleanLiteral b:
writer.WriteBooleanValue(b.Value);
break;
case PolicyIrListLiteral list:
writer.WriteStartArray();
foreach (var item in list.Items)
{
WriteLiteral(writer, item);
}
writer.WriteEndArray();
break;
default:
writer.WriteNullValue();
break;
}
}
private static void WriteLiteralValue(Utf8JsonWriter writer, object? value)
{
switch (value)
{
case null:
writer.WriteNullValue();
break;
case string s:
writer.WriteStringValue(s);
break;
case bool b:
writer.WriteBooleanValue(b);
break;
case decimal dec:
writer.WriteNumberValue(dec);
break;
case double dbl:
writer.WriteNumberValue(dbl);
break;
case int i:
writer.WriteNumberValue(i);
break;
default:
writer.WriteStringValue(value.ToString());
break;
}
}
}

View File

@@ -1,141 +1,141 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Engine.Compilation;
public abstract record SyntaxNode(SourceSpan Span);
public sealed record PolicyDocumentNode(
string Name,
string Syntax,
ImmutableDictionary<string, PolicyLiteralValue> Metadata,
ImmutableArray<PolicyProfileNode> Profiles,
ImmutableDictionary<string, PolicyLiteralValue> Settings,
ImmutableArray<PolicyRuleNode> Rules,
SourceSpan Span) : SyntaxNode(Span);
public sealed record PolicyProfileNode(
string Name,
ImmutableArray<PolicyProfileItemNode> Items,
SourceSpan Span) : SyntaxNode(Span);
public abstract record PolicyProfileItemNode(SourceSpan Span);
public sealed record PolicyProfileMapNode(
string Name,
ImmutableArray<PolicyProfileMapEntryNode> Entries,
SourceSpan Span) : PolicyProfileItemNode(Span);
public sealed record PolicyProfileMapEntryNode(
string Source,
decimal Weight,
SourceSpan Span) : SyntaxNode(Span);
public sealed record PolicyProfileEnvNode(
string Name,
ImmutableArray<PolicyProfileEnvEntryNode> Entries,
SourceSpan Span) : PolicyProfileItemNode(Span);
public sealed record PolicyProfileEnvEntryNode(
PolicyExpression Condition,
decimal Weight,
SourceSpan Span) : SyntaxNode(Span);
public sealed record PolicyProfileScalarNode(
string Name,
PolicyLiteralValue Value,
SourceSpan Span) : PolicyProfileItemNode(Span);
public sealed record PolicyRuleNode(
string Name,
int Priority,
PolicyExpression When,
ImmutableArray<PolicyActionNode> ThenActions,
ImmutableArray<PolicyActionNode> ElseActions,
string? Because,
SourceSpan Span) : SyntaxNode(Span);
public abstract record PolicyActionNode(SourceSpan Span);
public sealed record PolicyAssignmentActionNode(
PolicyReference Target,
PolicyExpression Value,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyAnnotateActionNode(
PolicyReference Target,
PolicyExpression Value,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyIgnoreActionNode(
PolicyExpression? Until,
string? Because,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyEscalateActionNode(
PolicyExpression? To,
PolicyExpression? When,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyRequireVexActionNode(
ImmutableDictionary<string, PolicyExpression> Conditions,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyWarnActionNode(
PolicyExpression? Message,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyDeferActionNode(
PolicyExpression? Until,
SourceSpan Span) : PolicyActionNode(Span);
public abstract record PolicyExpression(SourceSpan Span);
public sealed record PolicyLiteralExpression(object? Value, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyListExpression(ImmutableArray<PolicyExpression> Items, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyIdentifierExpression(string Name, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyMemberAccessExpression(PolicyExpression Target, string Member, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyInvocationExpression(PolicyExpression Target, ImmutableArray<PolicyExpression> Arguments, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyIndexerExpression(PolicyExpression Target, PolicyExpression Index, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyUnaryExpression(PolicyUnaryOperator Operator, PolicyExpression Operand, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyBinaryExpression(PolicyExpression Left, PolicyBinaryOperator Operator, PolicyExpression Right, SourceSpan Span) : PolicyExpression(Span);
public enum PolicyUnaryOperator
{
Not,
}
public enum PolicyBinaryOperator
{
And,
Or,
Equal,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual,
In,
NotIn,
}
public sealed record PolicyReference(ImmutableArray<string> Segments, SourceSpan Span)
{
public override string ToString() => string.Join(".", Segments);
}
public abstract record PolicyLiteralValue(SourceSpan Span);
public sealed record PolicyStringLiteral(string Value, SourceSpan Span) : PolicyLiteralValue(Span);
public sealed record PolicyNumberLiteral(decimal Value, SourceSpan Span) : PolicyLiteralValue(Span);
public sealed record PolicyBooleanLiteral(bool Value, SourceSpan Span) : PolicyLiteralValue(Span);
public sealed record PolicyListLiteral(ImmutableArray<PolicyLiteralValue> Items, SourceSpan Span) : PolicyLiteralValue(Span);
using System.Collections.Immutable;
namespace StellaOps.PolicyDsl;
public abstract record SyntaxNode(SourceSpan Span);
public sealed record PolicyDocumentNode(
string Name,
string Syntax,
ImmutableDictionary<string, PolicyLiteralValue> Metadata,
ImmutableArray<PolicyProfileNode> Profiles,
ImmutableDictionary<string, PolicyLiteralValue> Settings,
ImmutableArray<PolicyRuleNode> Rules,
SourceSpan Span) : SyntaxNode(Span);
public sealed record PolicyProfileNode(
string Name,
ImmutableArray<PolicyProfileItemNode> Items,
SourceSpan Span) : SyntaxNode(Span);
public abstract record PolicyProfileItemNode(SourceSpan Span);
public sealed record PolicyProfileMapNode(
string Name,
ImmutableArray<PolicyProfileMapEntryNode> Entries,
SourceSpan Span) : PolicyProfileItemNode(Span);
public sealed record PolicyProfileMapEntryNode(
string Source,
decimal Weight,
SourceSpan Span) : SyntaxNode(Span);
public sealed record PolicyProfileEnvNode(
string Name,
ImmutableArray<PolicyProfileEnvEntryNode> Entries,
SourceSpan Span) : PolicyProfileItemNode(Span);
public sealed record PolicyProfileEnvEntryNode(
PolicyExpression Condition,
decimal Weight,
SourceSpan Span) : SyntaxNode(Span);
public sealed record PolicyProfileScalarNode(
string Name,
PolicyLiteralValue Value,
SourceSpan Span) : PolicyProfileItemNode(Span);
public sealed record PolicyRuleNode(
string Name,
int Priority,
PolicyExpression When,
ImmutableArray<PolicyActionNode> ThenActions,
ImmutableArray<PolicyActionNode> ElseActions,
string? Because,
SourceSpan Span) : SyntaxNode(Span);
public abstract record PolicyActionNode(SourceSpan Span);
public sealed record PolicyAssignmentActionNode(
PolicyReference Target,
PolicyExpression Value,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyAnnotateActionNode(
PolicyReference Target,
PolicyExpression Value,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyIgnoreActionNode(
PolicyExpression? Until,
string? Because,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyEscalateActionNode(
PolicyExpression? To,
PolicyExpression? When,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyRequireVexActionNode(
ImmutableDictionary<string, PolicyExpression> Conditions,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyWarnActionNode(
PolicyExpression? Message,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyDeferActionNode(
PolicyExpression? Until,
SourceSpan Span) : PolicyActionNode(Span);
public abstract record PolicyExpression(SourceSpan Span);
public sealed record PolicyLiteralExpression(object? Value, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyListExpression(ImmutableArray<PolicyExpression> Items, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyIdentifierExpression(string Name, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyMemberAccessExpression(PolicyExpression Target, string Member, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyInvocationExpression(PolicyExpression Target, ImmutableArray<PolicyExpression> Arguments, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyIndexerExpression(PolicyExpression Target, PolicyExpression Index, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyUnaryExpression(PolicyUnaryOperator Operator, PolicyExpression Operand, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyBinaryExpression(PolicyExpression Left, PolicyBinaryOperator Operator, PolicyExpression Right, SourceSpan Span) : PolicyExpression(Span);
public enum PolicyUnaryOperator
{
Not,
}
public enum PolicyBinaryOperator
{
And,
Or,
Equal,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual,
In,
NotIn,
}
public sealed record PolicyReference(ImmutableArray<string> Segments, SourceSpan Span)
{
public override string ToString() => string.Join(".", Segments);
}
public abstract record PolicyLiteralValue(SourceSpan Span);
public sealed record PolicyStringLiteral(string Value, SourceSpan Span) : PolicyLiteralValue(Span);
public sealed record PolicyNumberLiteral(decimal Value, SourceSpan Span) : PolicyLiteralValue(Span);
public sealed record PolicyBooleanLiteral(bool Value, SourceSpan Span) : PolicyLiteralValue(Span);
public sealed record PolicyListLiteral(ImmutableArray<PolicyLiteralValue> Items, SourceSpan Span) : PolicyLiteralValue(Span);

View File

@@ -0,0 +1,216 @@
namespace StellaOps.PolicyDsl;
/// <summary>
/// Provides signal values for policy evaluation.
/// </summary>
public sealed class SignalContext
{
private readonly Dictionary<string, object?> _signals;
/// <summary>
/// Creates an empty signal context.
/// </summary>
public SignalContext()
{
_signals = new Dictionary<string, object?>(StringComparer.Ordinal);
}
/// <summary>
/// Creates a signal context with initial values.
/// </summary>
/// <param name="signals">Initial signal values.</param>
public SignalContext(IDictionary<string, object?> signals)
{
_signals = new Dictionary<string, object?>(signals, StringComparer.Ordinal);
}
/// <summary>
/// Gets whether a signal exists.
/// </summary>
/// <param name="name">The signal name.</param>
/// <returns>True if the signal exists.</returns>
public bool HasSignal(string name) => _signals.ContainsKey(name);
/// <summary>
/// Gets a signal value.
/// </summary>
/// <param name="name">The signal name.</param>
/// <returns>The signal value, or null if not found.</returns>
public object? GetSignal(string name) => _signals.TryGetValue(name, out var value) ? value : null;
/// <summary>
/// Gets a signal value as a specific type.
/// </summary>
/// <typeparam name="T">The expected type.</typeparam>
/// <param name="name">The signal name.</param>
/// <returns>The signal value, or default if not found or wrong type.</returns>
public T? GetSignal<T>(string name) => _signals.TryGetValue(name, out var value) && value is T t ? t : default;
/// <summary>
/// Sets a signal value.
/// </summary>
/// <param name="name">The signal name.</param>
/// <param name="value">The signal value.</param>
/// <returns>This context for chaining.</returns>
public SignalContext SetSignal(string name, object? value)
{
_signals[name] = value;
return this;
}
/// <summary>
/// Removes a signal.
/// </summary>
/// <param name="name">The signal name.</param>
/// <returns>This context for chaining.</returns>
public SignalContext RemoveSignal(string name)
{
_signals.Remove(name);
return this;
}
/// <summary>
/// Gets all signal names.
/// </summary>
public IEnumerable<string> SignalNames => _signals.Keys;
/// <summary>
/// Gets all signals as a read-only dictionary.
/// </summary>
public IReadOnlyDictionary<string, object?> Signals => _signals;
/// <summary>
/// Creates a copy of this context.
/// </summary>
/// <returns>A new context with the same signals.</returns>
public SignalContext Clone() => new(_signals);
/// <summary>
/// Creates a signal context builder for fluent construction.
/// </summary>
/// <returns>A new builder.</returns>
public static SignalContextBuilder Builder() => new();
}
/// <summary>
/// Builder for creating signal contexts with fluent API.
/// </summary>
public sealed class SignalContextBuilder
{
private readonly Dictionary<string, object?> _signals = new(StringComparer.Ordinal);
/// <summary>
/// Adds a signal to the context.
/// </summary>
/// <param name="name">The signal name.</param>
/// <param name="value">The signal value.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithSignal(string name, object? value)
{
_signals[name] = value;
return this;
}
/// <summary>
/// Adds a boolean signal to the context.
/// </summary>
/// <param name="name">The signal name.</param>
/// <param name="value">The boolean value.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithFlag(string name, bool value = true)
{
_signals[name] = value;
return this;
}
/// <summary>
/// Adds a numeric signal to the context.
/// </summary>
/// <param name="name">The signal name.</param>
/// <param name="value">The numeric value.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithNumber(string name, decimal value)
{
_signals[name] = value;
return this;
}
/// <summary>
/// Adds a string signal to the context.
/// </summary>
/// <param name="name">The signal name.</param>
/// <param name="value">The string value.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithString(string name, string value)
{
_signals[name] = value;
return this;
}
/// <summary>
/// Adds a nested object signal to the context.
/// </summary>
/// <param name="name">The signal name.</param>
/// <param name="properties">The nested properties.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithObject(string name, IDictionary<string, object?> properties)
{
_signals[name] = new Dictionary<string, object?>(properties, StringComparer.Ordinal);
return this;
}
/// <summary>
/// Adds common finding signals.
/// </summary>
/// <param name="severity">The finding severity (e.g., "critical", "high", "medium", "low").</param>
/// <param name="confidence">The confidence score (0.0 to 1.0).</param>
/// <param name="cveId">Optional CVE identifier.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithFinding(string severity, decimal confidence, string? cveId = null)
{
_signals["finding"] = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["severity"] = severity,
["confidence"] = confidence,
["cve_id"] = cveId,
};
return this;
}
/// <summary>
/// Adds common reachability signals.
/// </summary>
/// <param name="state">The reachability state (e.g., "reachable", "unreachable", "unknown").</param>
/// <param name="confidence">The confidence score (0.0 to 1.0).</param>
/// <param name="hasRuntimeEvidence">Whether there is runtime evidence.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithReachability(string state, decimal confidence, bool hasRuntimeEvidence = false)
{
_signals["reachability"] = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["state"] = state,
["confidence"] = confidence,
["has_runtime_evidence"] = hasRuntimeEvidence,
};
return this;
}
/// <summary>
/// Adds common trust score signals.
/// </summary>
/// <param name="score">The trust score (0.0 to 1.0).</param>
/// <param name="verified">Whether the source is verified.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithTrustScore(decimal score, bool verified = false)
{
_signals["trust_score"] = score;
_signals["trust_verified"] = verified;
return this;
}
/// <summary>
/// Builds the signal context.
/// </summary>
/// <returns>A new signal context with the configured signals.</returns>
public SignalContext Build() => new(_signals);
}

View File

@@ -1,160 +1,97 @@
using System.Diagnostics.CodeAnalysis;
namespace StellaOps.Policy.Engine.Compilation;
/// <summary>
/// Represents a precise source location within a policy DSL document.
/// </summary>
public readonly struct SourceLocation : IEquatable<SourceLocation>, IComparable<SourceLocation>
{
public SourceLocation(int offset, int line, int column)
{
if (offset < 0)
{
throw new ArgumentOutOfRangeException(nameof(offset));
}
if (line < 1)
{
throw new ArgumentOutOfRangeException(nameof(line));
}
if (column < 1)
{
throw new ArgumentOutOfRangeException(nameof(column));
}
Offset = offset;
Line = line;
Column = column;
}
public int Offset { get; }
public int Line { get; }
public int Column { get; }
public override string ToString() => $"(L{Line}, C{Column})";
public bool Equals(SourceLocation other) =>
Offset == other.Offset && Line == other.Line && Column == other.Column;
public override bool Equals([NotNullWhen(true)] object? obj) =>
obj is SourceLocation other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Offset, Line, Column);
public int CompareTo(SourceLocation other) => Offset.CompareTo(other.Offset);
public static bool operator ==(SourceLocation left, SourceLocation right) => left.Equals(right);
public static bool operator !=(SourceLocation left, SourceLocation right) => !left.Equals(right);
public static bool operator <(SourceLocation left, SourceLocation right) => left.CompareTo(right) < 0;
public static bool operator <=(SourceLocation left, SourceLocation right) => left.CompareTo(right) <= 0;
public static bool operator >(SourceLocation left, SourceLocation right) => left.CompareTo(right) > 0;
public static bool operator >=(SourceLocation left, SourceLocation right) => left.CompareTo(right) >= 0;
}
/// <summary>
/// Represents a start/end location pair within a policy DSL source document.
/// </summary>
public readonly struct SourceSpan : IEquatable<SourceSpan>
{
public SourceSpan(SourceLocation start, SourceLocation end)
{
if (start.Offset > end.Offset)
{
throw new ArgumentException("Start must not be after end.", nameof(start));
}
Start = start;
End = end;
}
public SourceLocation Start { get; }
public SourceLocation End { get; }
public override string ToString() => $"{Start}->{End}";
public bool Equals(SourceSpan other) => Start.Equals(other.Start) && End.Equals(other.End);
public override bool Equals([NotNullWhen(true)] object? obj) => obj is SourceSpan other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Start, End);
public static SourceSpan Combine(SourceSpan first, SourceSpan second)
{
var start = first.Start <= second.Start ? first.Start : second.Start;
var end = first.End >= second.End ? first.End : second.End;
return new SourceSpan(start, end);
}
}
internal enum TokenKind
{
EndOfFile = 0,
Identifier,
StringLiteral,
NumberLiteral,
BooleanLiteral,
LeftBrace,
RightBrace,
LeftParen,
RightParen,
LeftBracket,
RightBracket,
Comma,
Semicolon,
Colon,
Arrow, // =>
Assign, // =
Define, // :=
Dot,
KeywordPolicy,
KeywordSyntax,
KeywordMetadata,
KeywordProfile,
KeywordRule,
KeywordMap,
KeywordSource,
KeywordEnv,
KeywordIf,
KeywordThen,
KeywordWhen,
KeywordAnd,
KeywordOr,
KeywordNot,
KeywordPriority,
KeywordElse,
KeywordBecause,
KeywordSettings,
KeywordIgnore,
KeywordUntil,
KeywordEscalate,
KeywordTo,
KeywordRequireVex,
KeywordWarn,
KeywordMessage,
KeywordDefer,
KeywordAnnotate,
KeywordIn,
EqualEqual,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual,
Unknown,
}
internal readonly record struct DslToken(
TokenKind Kind,
string Text,
SourceSpan Span,
object? Value = null);
using System.Diagnostics.CodeAnalysis;
namespace StellaOps.PolicyDsl;
/// <summary>
/// Represents a precise source location within a policy DSL document.
/// </summary>
public readonly struct SourceLocation : IEquatable<SourceLocation>, IComparable<SourceLocation>
{
public SourceLocation(int offset, int line, int column)
{
if (offset < 0)
{
throw new ArgumentOutOfRangeException(nameof(offset));
}
if (line < 1)
{
throw new ArgumentOutOfRangeException(nameof(line));
}
if (column < 1)
{
throw new ArgumentOutOfRangeException(nameof(column));
}
Offset = offset;
Line = line;
Column = column;
}
public int Offset { get; }
public int Line { get; }
public int Column { get; }
public override string ToString() => $"(L{Line}, C{Column})";
public bool Equals(SourceLocation other) =>
Offset == other.Offset && Line == other.Line && Column == other.Column;
public override bool Equals([NotNullWhen(true)] object? obj) =>
obj is SourceLocation other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Offset, Line, Column);
public int CompareTo(SourceLocation other) => Offset.CompareTo(other.Offset);
public static bool operator ==(SourceLocation left, SourceLocation right) => left.Equals(right);
public static bool operator !=(SourceLocation left, SourceLocation right) => !left.Equals(right);
public static bool operator <(SourceLocation left, SourceLocation right) => left.CompareTo(right) < 0;
public static bool operator <=(SourceLocation left, SourceLocation right) => left.CompareTo(right) <= 0;
public static bool operator >(SourceLocation left, SourceLocation right) => left.CompareTo(right) > 0;
public static bool operator >=(SourceLocation left, SourceLocation right) => left.CompareTo(right) >= 0;
}
/// <summary>
/// Represents a start/end location pair within a policy DSL source document.
/// </summary>
public readonly struct SourceSpan : IEquatable<SourceSpan>
{
public SourceSpan(SourceLocation start, SourceLocation end)
{
if (start.Offset > end.Offset)
{
throw new ArgumentException("Start must not be after end.", nameof(start));
}
Start = start;
End = end;
}
public SourceLocation Start { get; }
public SourceLocation End { get; }
public override string ToString() => $"{Start}->{End}";
public bool Equals(SourceSpan other) => Start.Equals(other.Start) && End.Equals(other.End);
public override bool Equals([NotNullWhen(true)] object? obj) => obj is SourceSpan other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Start, End);
public static SourceSpan Combine(SourceSpan first, SourceSpan second)
{
var start = first.Start <= second.Start ? first.Start : second.Start;
var end = first.End >= second.End ? first.End : second.End;
return new SourceSpan(start, end);
}
}

View File

@@ -0,0 +1,20 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.PolicyDsl.Tests" />
<InternalsVisibleTo Include="StellaOps.Policy.Engine" />
<InternalsVisibleTo Include="StellaOps.Policy.Engine.Tests" />
</ItemGroup>
</Project>

View File

@@ -6,12 +6,12 @@ namespace StellaOps.Policy;
public static class PolicyEvaluation
{
public static PolicyVerdict EvaluateFinding(
PolicyDocument document,
PolicyScoringConfig scoringConfig,
PolicyFinding finding,
out PolicyExplanation? explanation)
{
public static PolicyVerdict EvaluateFinding(
PolicyDocument document,
PolicyScoringConfig scoringConfig,
PolicyFinding finding,
out PolicyExplanation? explanation)
{
if (document is null)
{
throw new ArgumentNullException(nameof(document));
@@ -44,49 +44,49 @@ public static class PolicyEvaluation
resolvedReachabilityKey);
var unknownConfidence = ComputeUnknownConfidence(scoringConfig.UnknownConfidence, finding);
foreach (var rule in document.Rules)
{
if (!RuleMatches(rule, finding))
{
continue;
}
return BuildVerdict(rule, finding, scoringConfig, components, unknownConfidence, out explanation);
}
explanation = new PolicyExplanation(
finding.FindingId,
PolicyVerdictStatus.Allowed,
null,
"No rule matched; baseline applied",
ImmutableArray.Create(PolicyExplanationNode.Leaf("rule", "No matching rule")));
var baseline = PolicyVerdict.CreateBaseline(finding.FindingId, scoringConfig);
return ApplyUnknownConfidence(baseline, unknownConfidence);
}
foreach (var rule in document.Rules)
{
if (!RuleMatches(rule, finding))
{
continue;
}
private static PolicyVerdict BuildVerdict(
PolicyRule rule,
PolicyFinding finding,
PolicyScoringConfig config,
ScoringComponents components,
UnknownConfidenceResult? unknownConfidence,
out PolicyExplanation explanation)
{
return BuildVerdict(rule, finding, scoringConfig, components, unknownConfidence, out explanation);
}
explanation = new PolicyExplanation(
finding.FindingId,
PolicyVerdictStatus.Pass,
null,
"No rule matched; baseline applied",
ImmutableArray.Create(PolicyExplanationNode.Leaf("rule", "No matching rule")));
var baseline = PolicyVerdict.CreateBaseline(finding.FindingId, scoringConfig);
return ApplyUnknownConfidence(baseline, unknownConfidence);
}
private static PolicyVerdict BuildVerdict(
PolicyRule rule,
PolicyFinding finding,
PolicyScoringConfig config,
ScoringComponents components,
UnknownConfidenceResult? unknownConfidence,
out PolicyExplanation explanation)
{
var action = rule.Action;
var status = MapAction(action);
var notes = BuildNotes(action);
var explanationNodes = ImmutableArray.CreateBuilder<PolicyExplanationNode>();
explanationNodes.Add(PolicyExplanationNode.Leaf("rule", $"Matched rule '{rule.Name}'", rule.Identifier));
var notes = BuildNotes(action);
var explanationNodes = ImmutableArray.CreateBuilder<PolicyExplanationNode>();
explanationNodes.Add(PolicyExplanationNode.Leaf("rule", $"Matched rule '{rule.Name}'", rule.Identifier));
var inputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
inputs["severityWeight"] = components.SeverityWeight;
inputs["trustWeight"] = components.TrustWeight;
inputs["reachabilityWeight"] = components.ReachabilityWeight;
inputs["baseScore"] = components.BaseScore;
explanationNodes.Add(PolicyExplanationNode.Branch("score", "Base score", components.BaseScore.ToString(CultureInfo.InvariantCulture),
PolicyExplanationNode.Leaf("severityWeight", "Severity weight", components.SeverityWeight.ToString(CultureInfo.InvariantCulture)),
PolicyExplanationNode.Leaf("trustWeight", "Trust weight", components.TrustWeight.ToString(CultureInfo.InvariantCulture)),
PolicyExplanationNode.Leaf("reachabilityWeight", "Reachability weight", components.ReachabilityWeight.ToString(CultureInfo.InvariantCulture))));
inputs["baseScore"] = components.BaseScore;
explanationNodes.Add(PolicyExplanationNode.Branch("score", "Base score", components.BaseScore.ToString(CultureInfo.InvariantCulture),
PolicyExplanationNode.Leaf("severityWeight", "Severity weight", components.SeverityWeight.ToString(CultureInfo.InvariantCulture)),
PolicyExplanationNode.Leaf("trustWeight", "Trust weight", components.TrustWeight.ToString(CultureInfo.InvariantCulture)),
PolicyExplanationNode.Leaf("reachabilityWeight", "Reachability weight", components.ReachabilityWeight.ToString(CultureInfo.InvariantCulture))));
if (!string.IsNullOrWhiteSpace(components.TrustKey))
{
inputs[$"trustWeight.{components.TrustKey}"] = components.TrustWeight;
@@ -97,14 +97,14 @@ public static class PolicyEvaluation
}
if (unknownConfidence is { Band.Description: { Length: > 0 } description })
{
notes = AppendNote(notes, description);
explanationNodes.Add(PolicyExplanationNode.Leaf("unknown", description));
}
if (unknownConfidence is { } unknownDetails)
{
inputs["unknownConfidence"] = unknownDetails.Confidence;
inputs["unknownAgeDays"] = unknownDetails.AgeDays;
}
notes = AppendNote(notes, description);
explanationNodes.Add(PolicyExplanationNode.Leaf("unknown", description));
}
if (unknownConfidence is { } unknownDetails)
{
inputs["unknownConfidence"] = unknownDetails.Confidence;
inputs["unknownAgeDays"] = unknownDetails.AgeDays;
}
double score = components.BaseScore;
string? quietedBy = null;
@@ -113,8 +113,8 @@ public static class PolicyEvaluation
var quietRequested = action.Quiet;
var quietAllowed = quietRequested && (action.RequireVex is not null || action.Type == PolicyActionType.RequireVex);
if (quietRequested && !quietAllowed)
{
if (quietRequested && !quietAllowed)
{
var warnInputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in inputs)
{
@@ -131,17 +131,17 @@ public static class PolicyEvaluation
var warnScore = Math.Max(0, components.BaseScore - warnPenalty);
var warnNotes = AppendNote(notes, "Quiet flag ignored: rule must specify requireVex justifications.");
explanation = new PolicyExplanation(
finding.FindingId,
PolicyVerdictStatus.Warned,
rule.Name,
"Quiet flag ignored; requireVex not provided",
explanationNodes.ToImmutable());
return new PolicyVerdict(
finding.FindingId,
PolicyVerdictStatus.Warned,
rule.Name,
explanation = new PolicyExplanation(
finding.FindingId,
PolicyVerdictStatus.Warned,
rule.Name,
"Quiet flag ignored; requireVex not provided",
explanationNodes.ToImmutable());
return new PolicyVerdict(
finding.FindingId,
PolicyVerdictStatus.Warned,
rule.Name,
action.Type.ToString(),
warnNotes,
warnScore,
@@ -156,56 +156,49 @@ public static class PolicyEvaluation
Reachability: components.ReachabilityKey);
}
if (status != PolicyVerdictStatus.Allowed)
{
explanationNodes.Add(PolicyExplanationNode.Leaf("action", $"Action {action.Type}", status.ToString()));
}
switch (status)
{
case PolicyVerdictStatus.Ignored:
score = ApplyPenalty(score, config.IgnorePenalty, inputs, "ignorePenalty");
explanationNodes.Add(PolicyExplanationNode.Leaf("penalty", "Ignore penalty", config.IgnorePenalty.ToString(CultureInfo.InvariantCulture)));
break;
case PolicyVerdictStatus.Warned:
score = ApplyPenalty(score, config.WarnPenalty, inputs, "warnPenalty");
explanationNodes.Add(PolicyExplanationNode.Leaf("penalty", "Warn penalty", config.WarnPenalty.ToString(CultureInfo.InvariantCulture)));
break;
case PolicyVerdictStatus.Deferred:
var deferPenalty = config.WarnPenalty / 2;
score = ApplyPenalty(score, deferPenalty, inputs, "deferPenalty");
explanationNodes.Add(PolicyExplanationNode.Leaf("penalty", "Defer penalty", deferPenalty.ToString(CultureInfo.InvariantCulture)));
break;
}
if (status != PolicyVerdictStatus.Pass)
{
explanationNodes.Add(PolicyExplanationNode.Leaf("action", $"Action {action.Type}", status.ToString()));
}
if (quietAllowed)
{
score = ApplyPenalty(score, config.QuietPenalty, inputs, "quietPenalty");
quietedBy = rule.Name;
quiet = true;
explanationNodes.Add(PolicyExplanationNode.Leaf("quiet", "Quiet applied", config.QuietPenalty.ToString(CultureInfo.InvariantCulture)));
}
explanation = new PolicyExplanation(
finding.FindingId,
status,
rule.Name,
notes,
explanationNodes.ToImmutable());
explanation = new PolicyExplanation(
finding.FindingId,
status,
rule.Name,
notes,
explanationNodes.ToImmutable());
return new PolicyVerdict(
finding.FindingId,
status,
rule.Name,
action.Type.ToString(),
notes,
switch (status)
{
case PolicyVerdictStatus.Ignored:
score = ApplyPenalty(score, config.IgnorePenalty, inputs, "ignorePenalty");
explanationNodes.Add(PolicyExplanationNode.Leaf("penalty", "Ignore penalty", config.IgnorePenalty.ToString(CultureInfo.InvariantCulture)));
break;
case PolicyVerdictStatus.Warned:
score = ApplyPenalty(score, config.WarnPenalty, inputs, "warnPenalty");
explanationNodes.Add(PolicyExplanationNode.Leaf("penalty", "Warn penalty", config.WarnPenalty.ToString(CultureInfo.InvariantCulture)));
break;
case PolicyVerdictStatus.Deferred:
var deferPenalty = config.WarnPenalty / 2;
score = ApplyPenalty(score, deferPenalty, inputs, "deferPenalty");
explanationNodes.Add(PolicyExplanationNode.Leaf("penalty", "Defer penalty", deferPenalty.ToString(CultureInfo.InvariantCulture)));
break;
}
if (quietAllowed)
{
score = ApplyPenalty(score, config.QuietPenalty, inputs, "quietPenalty");
quietedBy = rule.Name;
quiet = true;
explanationNodes.Add(PolicyExplanationNode.Leaf("quiet", "Quiet applied", config.QuietPenalty.ToString(CultureInfo.InvariantCulture)));
}
explanation = new PolicyExplanation(
finding.FindingId,
status,
rule.Name,
notes ?? string.Empty,
explanationNodes.ToImmutable());
return new PolicyVerdict(
finding.FindingId,
status,
rule.Name,
action.Type.ToString(),
notes,
score,
config.Version,
inputs.ToImmutable(),
@@ -229,12 +222,12 @@ public static class PolicyEvaluation
return Math.Max(0, score - penalty);
}
private static PolicyVerdict ApplyUnknownConfidence(PolicyVerdict verdict, UnknownConfidenceResult? unknownConfidence)
{
if (unknownConfidence is null)
{
return verdict;
}
private static PolicyVerdict ApplyUnknownConfidence(PolicyVerdict verdict, UnknownConfidenceResult? unknownConfidence)
{
if (unknownConfidence is null)
{
return verdict;
}
var inputsBuilder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in verdict.GetInputs())
@@ -245,12 +238,12 @@ public static class PolicyEvaluation
inputsBuilder["unknownConfidence"] = unknownConfidence.Value.Confidence;
inputsBuilder["unknownAgeDays"] = unknownConfidence.Value.AgeDays;
return verdict with
{
Inputs = inputsBuilder.ToImmutable(),
UnknownConfidence = unknownConfidence.Value.Confidence,
ConfidenceBand = unknownConfidence.Value.Band.Name,
UnknownAgeDays = unknownConfidence.Value.AgeDays,
return verdict with
{
Inputs = inputsBuilder.ToImmutable(),
UnknownConfidence = unknownConfidence.Value.Confidence,
ConfidenceBand = unknownConfidence.Value.Band.Name,
UnknownAgeDays = unknownConfidence.Value.AgeDays,
};
}

View File

@@ -18,7 +18,7 @@ public sealed record PolicyExplanation(
ImmutableArray<PolicyExplanationNode> Nodes)
{
public static PolicyExplanation Allow(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
new(findingId, PolicyVerdictStatus.Allowed, ruleName, reason, nodes.ToImmutableArray());
new(findingId, PolicyVerdictStatus.Pass, ruleName, reason, nodes.ToImmutableArray());
public static PolicyExplanation Block(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
new(findingId, PolicyVerdictStatus.Blocked, ruleName, reason, nodes.ToImmutableArray());

View File

@@ -29,7 +29,7 @@ public static class SplCanonicalizer
public static byte[] CanonicalizeToUtf8(ReadOnlySpan<byte> json)
{
using var document = JsonDocument.Parse(json, DocumentOptions);
using var document = JsonDocument.Parse(json.ToArray().AsMemory(), DocumentOptions);
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))

View File

@@ -49,8 +49,8 @@ public static class SplLayeringEngine
private static JsonNode MergeToJsonNode(ReadOnlySpan<byte> basePolicyUtf8, ReadOnlySpan<byte> overlayPolicyUtf8)
{
using var baseDoc = JsonDocument.Parse(basePolicyUtf8, DocumentOptions);
using var overlayDoc = JsonDocument.Parse(overlayPolicyUtf8, DocumentOptions);
using var baseDoc = JsonDocument.Parse(basePolicyUtf8.ToArray().AsMemory(), DocumentOptions);
using var overlayDoc = JsonDocument.Parse(overlayPolicyUtf8.ToArray().AsMemory(), DocumentOptions);
var baseRoot = baseDoc.RootElement;
var overlayRoot = overlayDoc.RootElement;
@@ -209,4 +209,14 @@ public static class SplLayeringEngine
return element.Value.TryGetProperty(name, out var value) ? value : (JsonElement?)null;
}
private static JsonElement? GetPropertyOrNull(this JsonElement element, string name)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
return element.TryGetProperty(name, out var value) ? value : (JsonElement?)null;
}
}

View File

@@ -1,3 +1,4 @@
using Xunit;
using StellaOps.Policy.Engine.AdvisoryAI;
namespace StellaOps.Policy.Engine.Tests;

View File

@@ -1,3 +1,4 @@
using Xunit;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Services;

View File

@@ -1,3 +1,4 @@
using Xunit;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Ledger;
using StellaOps.Policy.Engine.Orchestration;

View File

@@ -1,3 +1,4 @@
using Xunit;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Orchestration;

View File

@@ -1,3 +1,4 @@
using Xunit;
using System.Threading.Tasks;
using StellaOps.Policy.Engine.Overlay;
using StellaOps.Policy.Engine.Services;

View File

@@ -1,3 +1,4 @@
using Xunit;
using System.Text.Json;
using System.Threading.Tasks;
using StellaOps.Policy.Engine.Overlay;

View File

@@ -1,3 +1,4 @@
using Xunit;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Policy.Engine.Streaming;

View File

@@ -1,3 +1,4 @@
using Xunit;
using System.Collections.Immutable;
using System.Collections.Immutable;
using Microsoft.Extensions.Options;

View File

@@ -1,7 +1,7 @@
using System;
using Microsoft.Extensions.Options;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.PolicyDsl;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Services;
using Xunit;

View File

@@ -1,7 +1,7 @@
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.PolicyDsl;
using Xunit;
using Xunit.Sdk;

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.PolicyDsl;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Engine.Services;
using Xunit;
@@ -51,26 +51,26 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
because "Respect strong vendor VEX claims."
}
rule alert_warn_eol_runtime priority 1 {
when severity.normalized <= "Medium"
and sbom.has_tag("runtime:eol")
then warn message "Runtime marked as EOL; upgrade recommended."
because "Deprecated runtime should be upgraded."
}
rule block_ruby_dev priority 4 {
when sbom.any_component(ruby.group("development") and ruby.declared_only())
then status := "blocked"
because "Development-only Ruby gems without install evidence cannot ship."
}
rule warn_ruby_git_sources {
when sbom.any_component(ruby.source("git"))
then warn message "Git-sourced Ruby gem present; review required."
because "Git-sourced Ruby dependencies require explicit review."
}
}
""";
rule alert_warn_eol_runtime priority 1 {
when severity.normalized <= "Medium"
and sbom.has_tag("runtime:eol")
then warn message "Runtime marked as EOL; upgrade recommended."
because "Deprecated runtime should be upgraded."
}
rule block_ruby_dev priority 4 {
when sbom.any_component(ruby.group("development") and ruby.declared_only())
then status := "blocked"
because "Development-only Ruby gems without install evidence cannot ship."
}
rule warn_ruby_git_sources {
when sbom.any_component(ruby.source("git"))
then warn message "Git-sourced Ruby gem present; review required."
because "Git-sourced Ruby dependencies require explicit review."
}
}
""";
private readonly PolicyCompiler compiler = new();
private readonly PolicyEvaluationService evaluationService = new();
@@ -125,11 +125,11 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
public void Evaluate_WarnRuleEmitsWarning()
{
var document = CompileBaseline();
var tags = ImmutableHashSet.Create("runtime:eol");
var context = CreateContext("Medium", "internal") with
{
Sbom = new PolicyEvaluationSbom(tags)
};
var tags = ImmutableHashSet.Create("runtime:eol");
var context = CreateContext("Medium", "internal") with
{
Sbom = new PolicyEvaluationSbom(tags)
};
var result = evaluationService.Evaluate(document, context);
@@ -273,74 +273,74 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
Assert.NotNull(result.AppliedException);
Assert.Equal("exc-rule", result.AppliedException!.ExceptionId);
Assert.Equal("Rule Critical Suppress", result.AppliedException!.Metadata["effectName"]);
Assert.Equal("alice", result.AppliedException!.Metadata["requestedBy"]);
Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]);
}
[Fact]
public void Evaluate_RubyDevComponentBlocked()
{
var document = CompileBaseline();
var component = CreateRubyComponent(
name: "dev-only",
version: "1.0.0",
groups: "development;test",
declaredOnly: true,
source: "https://rubygems.org/",
capabilities: new[] { "exec" });
var context = CreateContext("Medium", "internal") with
{
Sbom = new PolicyEvaluationSbom(
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableArray.Create(component))
};
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("block_ruby_dev", result.RuleName);
Assert.Equal("blocked", result.Status);
}
[Fact]
public void Evaluate_RubyGitComponentWarns()
{
var document = CompileBaseline();
var component = CreateRubyComponent(
name: "git-gem",
version: "0.5.0",
groups: "default",
declaredOnly: false,
source: "git:https://github.com/example/git-gem.git@0123456789abcdef0123456789abcdef01234567",
capabilities: Array.Empty<string>(),
schedulerCapabilities: new[] { "sidekiq" });
var context = CreateContext("Low", "internal") with
{
Sbom = new PolicyEvaluationSbom(
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableArray.Create(component))
};
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("warn_ruby_git_sources", result.RuleName);
Assert.Equal("warned", result.Status);
Assert.Contains(result.Warnings, warning => warning.Contains("Git-sourced", StringComparison.OrdinalIgnoreCase));
}
private PolicyIrDocument CompileBaseline()
{
var compilation = compiler.Compile(BaselinePolicy);
if (!compilation.Success)
{
Console.WriteLine(Describe(compilation.Diagnostics));
}
Assert.True(compilation.Success, Describe(compilation.Diagnostics));
return Assert.IsType<PolicyIrDocument>(compilation.Document);
}
Assert.Equal("alice", result.AppliedException!.Metadata["requestedBy"]);
Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]);
}
[Fact]
public void Evaluate_RubyDevComponentBlocked()
{
var document = CompileBaseline();
var component = CreateRubyComponent(
name: "dev-only",
version: "1.0.0",
groups: "development;test",
declaredOnly: true,
source: "https://rubygems.org/",
capabilities: new[] { "exec" });
var context = CreateContext("Medium", "internal") with
{
Sbom = new PolicyEvaluationSbom(
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableArray.Create(component))
};
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("block_ruby_dev", result.RuleName);
Assert.Equal("blocked", result.Status);
}
[Fact]
public void Evaluate_RubyGitComponentWarns()
{
var document = CompileBaseline();
var component = CreateRubyComponent(
name: "git-gem",
version: "0.5.0",
groups: "default",
declaredOnly: false,
source: "git:https://github.com/example/git-gem.git@0123456789abcdef0123456789abcdef01234567",
capabilities: Array.Empty<string>(),
schedulerCapabilities: new[] { "sidekiq" });
var context = CreateContext("Low", "internal") with
{
Sbom = new PolicyEvaluationSbom(
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableArray.Create(component))
};
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("warn_ruby_git_sources", result.RuleName);
Assert.Equal("warned", result.Status);
Assert.Contains(result.Warnings, warning => warning.Contains("Git-sourced", StringComparison.OrdinalIgnoreCase));
}
private PolicyIrDocument CompileBaseline()
{
var compilation = compiler.Compile(BaselinePolicy);
if (!compilation.Success)
{
Console.WriteLine(Describe(compilation.Diagnostics));
}
Assert.True(compilation.Success, Describe(compilation.Diagnostics));
return Assert.IsType<PolicyIrDocument>(compilation.Document);
}
private static PolicyEvaluationContext CreateContext(string severity, string exposure, PolicyEvaluationExceptions? exceptions = null)
{
@@ -352,67 +352,67 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)),
new PolicyEvaluationAdvisory("GHSA", ImmutableDictionary<string, string>.Empty),
PolicyEvaluationVexEvidence.Empty,
PolicyEvaluationSbom.Empty,
exceptions ?? PolicyEvaluationExceptions.Empty);
}
PolicyEvaluationSbom.Empty,
exceptions ?? PolicyEvaluationExceptions.Empty);
}
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
private static PolicyEvaluationComponent CreateRubyComponent(
string name,
string version,
string groups,
bool declaredOnly,
string source,
IEnumerable<string>? capabilities = null,
IEnumerable<string>? schedulerCapabilities = null)
{
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(groups))
{
metadataBuilder["groups"] = groups;
}
metadataBuilder["declaredOnly"] = declaredOnly ? "true" : "false";
if (!string.IsNullOrWhiteSpace(source))
{
metadataBuilder["source"] = source.Trim();
}
if (capabilities is not null)
{
foreach (var capability in capabilities)
{
if (!string.IsNullOrWhiteSpace(capability))
{
metadataBuilder[$"capability.{capability.Trim()}"] = "true";
}
}
}
if (schedulerCapabilities is not null)
{
var schedulerList = string.Join(
';',
schedulerCapabilities
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim()));
if (!string.IsNullOrWhiteSpace(schedulerList))
{
metadataBuilder["capability.scheduler"] = schedulerList;
}
}
metadataBuilder["lockfile"] = "Gemfile.lock";
return new PolicyEvaluationComponent(
name,
version,
"gem",
$"pkg:gem/{name}@{version}",
metadataBuilder.ToImmutable());
}
}
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
private static PolicyEvaluationComponent CreateRubyComponent(
string name,
string version,
string groups,
bool declaredOnly,
string source,
IEnumerable<string>? capabilities = null,
IEnumerable<string>? schedulerCapabilities = null)
{
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(groups))
{
metadataBuilder["groups"] = groups;
}
metadataBuilder["declaredOnly"] = declaredOnly ? "true" : "false";
if (!string.IsNullOrWhiteSpace(source))
{
metadataBuilder["source"] = source.Trim();
}
if (capabilities is not null)
{
foreach (var capability in capabilities)
{
if (!string.IsNullOrWhiteSpace(capability))
{
metadataBuilder[$"capability.{capability.Trim()}"] = "true";
}
}
}
if (schedulerCapabilities is not null)
{
var schedulerList = string.Join(
';',
schedulerCapabilities
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim()));
if (!string.IsNullOrWhiteSpace(schedulerList))
{
metadataBuilder["capability.scheduler"] = schedulerList;
}
}
metadataBuilder["lockfile"] = "Gemfile.lock";
return new PolicyEvaluationComponent(
name,
version,
"gem",
$"pkg:gem/{name}@{version}",
metadataBuilder.ToImmutable());
}
}

View File

@@ -1,3 +1,4 @@
using Xunit;
using System.Collections.Immutable;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Services;

View File

@@ -1,3 +1,4 @@
using Xunit;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Orchestration;

View File

@@ -1,3 +1,4 @@
using Xunit;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Ledger;
using StellaOps.Policy.Engine.Orchestration;

View File

@@ -6,9 +6,25 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj" />
</ItemGroup>

View File

@@ -1,3 +1,4 @@
using Xunit;
using StellaOps.Policy.Engine.TrustWeighting;
namespace StellaOps.Policy.Engine.Tests;

View File

@@ -1,3 +1,4 @@
using Xunit;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Ledger;
using StellaOps.Policy.Engine.Orchestration;

View File

@@ -0,0 +1,183 @@
using FluentAssertions;
using StellaOps.PolicyDsl;
using Xunit;
namespace StellaOps.PolicyDsl.Tests;
/// <summary>
/// Tests for the policy DSL compiler.
/// </summary>
public class PolicyCompilerTests
{
private readonly PolicyCompiler _compiler = new();
[Fact]
public void Compile_MinimalPolicy_Succeeds()
{
// Arrange - rule name is an identifier, not a string; then block has no braces; := for assignment
var source = """
policy "test" syntax "stella-dsl@1" {
rule always priority 1 {
when true
then
severity := "info"
because "always applies"
}
}
""";
// Act
var result = _compiler.Compile(source);
// Assert
result.Success.Should().BeTrue(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
result.Document.Should().NotBeNull();
result.Document!.Name.Should().Be("test");
result.Document.Syntax.Should().Be("stella-dsl@1");
result.Document.Rules.Should().HaveCount(1);
result.Checksum.Should().NotBeNullOrEmpty();
}
[Fact]
public void Compile_WithMetadata_ParsesCorrectly()
{
// Arrange
var source = """
policy "with-meta" syntax "stella-dsl@1" {
metadata {
version = "1.0.0"
author = "test"
}
rule r1 priority 1 {
when true
then
severity := "low"
because "required"
}
}
""";
// Act
var result = _compiler.Compile(source);
// Assert
result.Success.Should().BeTrue(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
result.Document!.Metadata.Should().ContainKey("version");
result.Document.Metadata.Should().ContainKey("author");
}
[Fact]
public void Compile_WithProfile_ParsesCorrectly()
{
// Arrange
var source = """
policy "with-profile" syntax "stella-dsl@1" {
profile standard {
trust_score = 0.85
}
rule r1 priority 1 {
when true
then
severity := "low"
because "required"
}
}
""";
// Act
var result = _compiler.Compile(source);
// Assert
result.Success.Should().BeTrue(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
result.Document!.Profiles.Should().HaveCount(1);
result.Document.Profiles[0].Name.Should().Be("standard");
}
[Fact]
public void Compile_EmptySource_ReturnsError()
{
// Arrange
var source = "";
// Act
var result = _compiler.Compile(source);
// Assert
result.Success.Should().BeFalse();
result.Diagnostics.Should().NotBeEmpty();
}
[Fact]
public void Compile_InvalidSyntax_ReturnsError()
{
// Arrange
var source = """
policy "bad" syntax "invalid@1" {
}
""";
// Act
var result = _compiler.Compile(source);
// Assert
result.Success.Should().BeFalse();
}
[Fact]
public void Compile_SameSource_ProducesSameChecksum()
{
// Arrange
var source = """
policy "deterministic" syntax "stella-dsl@1" {
rule r1 priority 1 {
when true
then
severity := "info"
because "always"
}
}
""";
// Act
var result1 = _compiler.Compile(source);
var result2 = _compiler.Compile(source);
// Assert
result1.Success.Should().BeTrue(string.Join("; ", result1.Diagnostics.Select(d => d.Message)));
result2.Success.Should().BeTrue(string.Join("; ", result2.Diagnostics.Select(d => d.Message)));
result1.Checksum.Should().Be(result2.Checksum);
}
[Fact]
public void Compile_DifferentSource_ProducesDifferentChecksum()
{
// Arrange
var source1 = """
policy "test1" syntax "stella-dsl@1" {
rule r1 priority 1 {
when true
then
severity := "info"
because "always"
}
}
""";
var source2 = """
policy "test2" syntax "stella-dsl@1" {
rule r1 priority 1 {
when true
then
severity := "info"
because "always"
}
}
""";
// Act
var result1 = _compiler.Compile(source1);
var result2 = _compiler.Compile(source2);
// Assert
result1.Checksum.Should().NotBe(result2.Checksum);
}
}

View File

@@ -0,0 +1,193 @@
using FluentAssertions;
using StellaOps.PolicyDsl;
using Xunit;
namespace StellaOps.PolicyDsl.Tests;
/// <summary>
/// Tests for the policy evaluation engine.
/// </summary>
public class PolicyEngineTests
{
private readonly PolicyEngineFactory _factory = new();
[Fact]
public void Evaluate_RuleMatches_ReturnsMatchedRules()
{
// Arrange
var source = """
policy "test" syntax "stella-dsl@1" {
rule critical_rule priority 100 {
when finding.severity == "critical"
then
severity := "critical"
because "critical finding detected"
}
}
""";
var result = _factory.CreateFromSource(source);
result.Engine.Should().NotBeNull(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
var engine = result.Engine!;
var context = SignalContext.Builder()
.WithObject("finding", new Dictionary<string, object?> { ["severity"] = "critical" })
.Build();
// Act
var evalResult = engine.Evaluate(context);
// Assert
evalResult.MatchedRules.Should().Contain("critical_rule");
evalResult.PolicyChecksum.Should().NotBeNullOrEmpty();
}
[Fact]
public void Evaluate_RuleDoesNotMatch_ExecutesElseBranch()
{
// Arrange
var source = """
policy "test" syntax "stella-dsl@1" {
rule critical_only priority 100 {
when finding.severity == "critical"
then
severity := "critical"
else
severity := "info"
because "classify by severity"
}
}
""";
var result = _factory.CreateFromSource(source);
result.Engine.Should().NotBeNull(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
var engine = result.Engine!;
var context = SignalContext.Builder()
.WithObject("finding", new Dictionary<string, object?> { ["severity"] = "low" })
.Build();
// Act
var evalResult = engine.Evaluate(context);
// Assert
evalResult.MatchedRules.Should().BeEmpty();
evalResult.Actions.Should().NotBeEmpty();
evalResult.Actions[0].WasElseBranch.Should().BeTrue();
}
[Fact]
public void Evaluate_MultipleRules_EvaluatesInPriorityOrder()
{
// Arrange
var source = """
policy "test" syntax "stella-dsl@1" {
rule low_priority priority 10 {
when true
then
severity := "low"
because "low priority rule"
}
rule high_priority priority 100 {
when true
then
severity := "high"
because "high priority rule"
}
}
""";
var result = _factory.CreateFromSource(source);
result.Engine.Should().NotBeNull(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
var engine = result.Engine!;
var context = new SignalContext();
// Act
var evalResult = engine.Evaluate(context);
// Assert
evalResult.MatchedRules.Should().HaveCount(2);
evalResult.MatchedRules[0].Should().Be("high_priority");
evalResult.MatchedRules[1].Should().Be("low_priority");
}
[Fact]
public void Evaluate_WithAndCondition_MatchesWhenBothTrue()
{
// Arrange
var source = """
policy "test" syntax "stella-dsl@1" {
rule combined priority 100 {
when finding.severity == "critical" and reachability.state == "reachable"
then
severity := "critical"
because "critical and reachable"
}
}
""";
var result = _factory.CreateFromSource(source);
result.Engine.Should().NotBeNull(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
var engine = result.Engine!;
var context = SignalContext.Builder()
.WithFinding("critical", 0.95m)
.WithReachability("reachable", 0.9m)
.Build();
// Act
var evalResult = engine.Evaluate(context);
// Assert
evalResult.MatchedRules.Should().Contain("combined");
}
[Fact]
public void Evaluate_WithOrCondition_MatchesWhenEitherTrue()
{
// Arrange
var source = """
policy "test" syntax "stella-dsl@1" {
rule either priority 100 {
when finding.severity == "critical" or finding.severity == "high"
then
severity := "elevated"
because "elevated severity"
}
}
""";
var result = _factory.CreateFromSource(source);
result.Engine.Should().NotBeNull(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
var engine = result.Engine!;
var context = SignalContext.Builder()
.WithObject("finding", new Dictionary<string, object?> { ["severity"] = "high" })
.Build();
// Act
var evalResult = engine.Evaluate(context);
// Assert
evalResult.MatchedRules.Should().Contain("either");
}
[Fact]
public void Evaluate_WithNotCondition_InvertsResult()
{
// Arrange
var source = """
policy "test" syntax "stella-dsl@1" {
rule not_critical priority 100 {
when not finding.is_critical
then
severity := "low"
because "not critical"
}
}
""";
var result = _factory.CreateFromSource(source);
result.Engine.Should().NotBeNull(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
var engine = result.Engine!;
var context = SignalContext.Builder()
.WithObject("finding", new Dictionary<string, object?> { ["is_critical"] = false })
.Build();
// Act
var evalResult = engine.Evaluate(context);
// Assert
evalResult.MatchedRules.Should().Contain("not_critical");
}
}

View File

@@ -0,0 +1,181 @@
using FluentAssertions;
using StellaOps.PolicyDsl;
using Xunit;
namespace StellaOps.PolicyDsl.Tests;
/// <summary>
/// Tests for the signal context API.
/// </summary>
public class SignalContextTests
{
[Fact]
public void Builder_WithSignal_SetsSignalValue()
{
// Arrange & Act
var context = SignalContext.Builder()
.WithSignal("test", "value")
.Build();
// Assert
context.GetSignal("test").Should().Be("value");
}
[Fact]
public void Builder_WithFlag_SetsBooleanSignal()
{
// Arrange & Act
var context = SignalContext.Builder()
.WithFlag("enabled")
.Build();
// Assert
context.GetSignal<bool>("enabled").Should().BeTrue();
}
[Fact]
public void Builder_WithNumber_SetsDecimalSignal()
{
// Arrange & Act
var context = SignalContext.Builder()
.WithNumber("score", 0.95m)
.Build();
// Assert
context.GetSignal<decimal>("score").Should().Be(0.95m);
}
[Fact]
public void Builder_WithString_SetsStringSignal()
{
// Arrange & Act
var context = SignalContext.Builder()
.WithString("name", "test")
.Build();
// Assert
context.GetSignal<string>("name").Should().Be("test");
}
[Fact]
public void Builder_WithFinding_SetsNestedFindingObject()
{
// Arrange & Act
var context = SignalContext.Builder()
.WithFinding("critical", 0.95m, "CVE-2024-1234")
.Build();
// Assert
context.HasSignal("finding").Should().BeTrue();
var finding = context.GetSignal("finding") as IDictionary<string, object?>;
finding.Should().NotBeNull();
finding!["severity"].Should().Be("critical");
finding["confidence"].Should().Be(0.95m);
finding["cve_id"].Should().Be("CVE-2024-1234");
}
[Fact]
public void Builder_WithReachability_SetsNestedReachabilityObject()
{
// Arrange & Act
var context = SignalContext.Builder()
.WithReachability("reachable", 0.9m, hasRuntimeEvidence: true)
.Build();
// Assert
context.HasSignal("reachability").Should().BeTrue();
var reachability = context.GetSignal("reachability") as IDictionary<string, object?>;
reachability.Should().NotBeNull();
reachability!["state"].Should().Be("reachable");
reachability["confidence"].Should().Be(0.9m);
reachability["has_runtime_evidence"].Should().Be(true);
}
[Fact]
public void Builder_WithTrustScore_SetsTrustSignals()
{
// Arrange & Act
var context = SignalContext.Builder()
.WithTrustScore(0.85m, verified: true)
.Build();
// Assert
context.GetSignal<decimal>("trust_score").Should().Be(0.85m);
context.GetSignal<bool>("trust_verified").Should().BeTrue();
}
[Fact]
public void SetSignal_UpdatesExistingValue()
{
// Arrange
var context = new SignalContext();
context.SetSignal("key", "value1");
// Act
context.SetSignal("key", "value2");
// Assert
context.GetSignal("key").Should().Be("value2");
}
[Fact]
public void RemoveSignal_RemovesExistingSignal()
{
// Arrange
var context = new SignalContext();
context.SetSignal("key", "value");
// Act
context.RemoveSignal("key");
// Assert
context.HasSignal("key").Should().BeFalse();
}
[Fact]
public void Clone_CreatesIndependentCopy()
{
// Arrange
var original = SignalContext.Builder()
.WithSignal("key", "value")
.Build();
// Act
var clone = original.Clone();
clone.SetSignal("key", "modified");
// Assert
original.GetSignal("key").Should().Be("value");
clone.GetSignal("key").Should().Be("modified");
}
[Fact]
public void SignalNames_ReturnsAllSignalKeys()
{
// Arrange
var context = SignalContext.Builder()
.WithSignal("a", 1)
.WithSignal("b", 2)
.WithSignal("c", 3)
.Build();
// Act & Assert
context.SignalNames.Should().BeEquivalentTo(new[] { "a", "b", "c" });
}
[Fact]
public void Signals_ReturnsReadOnlyDictionary()
{
// Arrange
var context = SignalContext.Builder()
.WithSignal("key", "value")
.Build();
// Act
var signals = context.Signals;
// Assert
signals.Should().ContainKey("key");
signals["key"].Should().Be("value");
}
}

View File

@@ -0,0 +1,35 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Disable Concelier test infra to avoid duplicate package references -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="TestData\*.dsl">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,56 @@
// Default reachability-aware policy
// syntax: stella-dsl@1
policy "default-reachability" syntax "stella-dsl@1" {
metadata {
version = "1.0.0"
description = "Default policy with reachability-aware rules"
author = "StellaOps"
}
settings {
default_action = "warn"
fail_on_critical = true
}
profile standard {
trust_score = 0.85
}
// Critical vulnerabilities with confirmed reachability
rule critical_reachable priority 100 {
when finding.severity == "critical" and reachability.state == "reachable"
then
severity := "critical"
annotate finding.priority := "immediate"
escalate to "security-team" when reachability.confidence > 0.9
because "Critical vulnerabilities with confirmed reachability require immediate action"
}
// High severity with runtime evidence
rule high_with_evidence priority 90 {
when finding.severity == "high" and reachability.has_runtime_evidence
then
severity := "high"
annotate finding.evidence := "runtime-confirmed"
else
defer until "reachability-assessment"
because "High severity findings need runtime evidence for prioritization"
}
// Low severity unreachable can be ignored
rule low_unreachable priority 50 {
when finding.severity == "low" and reachability.state == "unreachable"
then
ignore until "next-scan" because "Low severity unreachable code"
because "Low severity unreachable vulnerabilities can be safely deferred"
}
// Unknown reachability requires VEX
rule unknown_reachability priority 40 {
when not reachability.state
then
warn message "Reachability assessment pending"
because "Unknown reachability requires manual assessment"
}
}

View File

@@ -0,0 +1,11 @@
// Minimal valid policy
// syntax: stella-dsl@1
policy "minimal" syntax "stella-dsl@1" {
rule always_pass priority 1 {
when true
then
severity := "info"
because "always applies"
}
}