This commit is contained in:
StellaOps Bot
2025-11-23 23:40:10 +02:00
parent c13355923f
commit 029002ad05
93 changed files with 2160 additions and 285 deletions

View File

@@ -0,0 +1,42 @@
using System.Text;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.Streaming;
namespace StellaOps.Policy.Engine.Endpoints;
public static class PathScopeSimulationEndpoint
{
public static IEndpointRouteBuilder MapPathScopeSimulation(this IEndpointRouteBuilder routes)
{
routes.MapPost("/simulation/path-scope", HandleAsync)
.WithName("PolicyEngine.PathScopeSimulation")
.WithOpenApi();
return routes;
}
private static async Task<IResult> HandleAsync(
[FromBody] PathScopeSimulationRequest request,
PathScopeSimulationService service,
CancellationToken cancellationToken)
{
try
{
var stream = service.StreamAsync(request, cancellationToken);
var responseBuilder = new StringBuilder();
await foreach (var line in stream.ConfigureAwait(false))
{
responseBuilder.AppendLine(line);
}
return Results.Text(responseBuilder.ToString(), "application/x-ndjson", Encoding.UTF8);
}
catch (PathScopeSimulationException ex)
{
var errorLine = JsonSerializer.Serialize(ex.Error);
return Results.Text(errorLine + "\n", "application/x-ndjson", Encoding.UTF8, StatusCodes.Status400BadRequest);
}
}
}

View File

@@ -7,10 +7,11 @@ 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.Policy.Engine.Services;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Endpoints;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Workers;
using StellaOps.Policy.Engine.Streaming;
using StellaOps.AirGap.Policy;
var builder = WebApplication.CreateBuilder(args);
@@ -105,9 +106,10 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineO
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
builder.Services.AddSingleton<PolicyCompiler>();
builder.Services.AddSingleton<PolicyCompilationService>();
builder.Services.AddSingleton<PolicyEvaluationService>();
builder.Services.AddSingleton<PolicyCompiler>();
builder.Services.AddSingleton<PolicyCompilationService>();
builder.Services.AddSingleton<PolicyEvaluationService>();
builder.Services.AddSingleton<PathScopeSimulationService>();
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
builder.Services.AddHttpContextAccessor();
@@ -144,16 +146,17 @@ var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz");
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
diagnostics.IsReady
? Results.Ok(new { status = "ready" })
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable))
.WithName("Readiness");
app.MapGet("/", () => Results.Redirect("/healthz"));
app.MapPolicyCompilation();
app.MapPolicyPacks();
app.Run();
app.MapHealthChecks("/healthz");
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
diagnostics.IsReady
? Results.Ok(new { status = "ready" })
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable))
.WithName("Readiness");
app.MapGet("/", () => Results.Redirect("/healthz"));
app.MapPolicyCompilation();
app.MapPolicyPacks();
app.MapPathScopeSimulation();
app.Run();

View File

@@ -0,0 +1,86 @@
using System.Security.Cryptography;
using StellaOps.Policy.Engine.Streaming;
namespace StellaOps.Policy.Engine.Services;
public sealed partial class PolicyEvaluationService
{
public Task<JsonObject> EvaluatePathScopeAsync(
PathScopeSimulationRequest request,
PathScopeTarget target,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var stableKey = string.Create(CultureInfo.InvariantCulture, $"{request.BasePolicyRef}|{request.CandidatePolicyRef}|{target.FilePath}|{target.Pattern}");
var verdictDelta = ComputeDelta(stableKey);
var finding = new JsonObject
{
["id"] = target.EvidenceHash ?? "stub-ghsa",
["ruleId"] = "policy.rules.path-scope.stub",
["severity"] = "info",
["verdict"] = new JsonObject
{
["base"] = verdictDelta.baseVerdict,
["candidate"] = verdictDelta.candidateVerdict,
["delta"] = verdictDelta.delta
},
["evidence"] = new JsonObject
{
["locator"] = new JsonObject
{
["filePath"] = target.FilePath,
["digest"] = target.Digest
},
["provenance"] = new JsonObject
{
["ingestedAt"] = target.IngestedAt?.ToString("O", CultureInfo.InvariantCulture),
["connectorId"] = target.ConnectorId
}
}
};
var envelope = new JsonObject
{
["tenant"] = request.Tenant,
["subject"] = JsonSerializer.SerializeToNode(request.Subject, SerializerOptions),
["target"] = new JsonObject
{
["filePath"] = target.FilePath,
["pattern"] = target.Pattern,
["pathMatch"] = target.PathMatch,
["confidence"] = target.Confidence,
["evidenceHash"] = target.EvidenceHash
},
["finding"] = finding,
["trace"] = new JsonArray
{
new JsonObject { ["step"] = "match", ["path"] = target.FilePath },
new JsonObject { ["step"] = "decision", ["effect"] = verdictDelta.candidateVerdict }
},
["metrics"] = new JsonObject
{
["evalTicks"] = stableKey.Length,
["rulesEvaluated"] = 1,
["bindings"] = 1
}
};
return Task.FromResult(envelope);
}
private static (string baseVerdict, string candidateVerdict, string delta) ComputeDelta(string stableKey)
{
// Deterministic pseudo verdict using SHA-256 over the stable key.
Span<byte> hashBytes = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(stableKey), hashBytes);
// Use lowest byte to determine delta.
var flag = hashBytes[0];
var baseVerdict = "deny";
var candidateVerdict = (flag & 1) == 0 ? "warn" : "deny";
var delta = baseVerdict == candidateVerdict ? "unchanged" : "softened";
return (baseVerdict, candidateVerdict, delta);
}
}

View File

@@ -1,17 +1,27 @@
using System.Collections.Immutable;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Evaluation;
namespace StellaOps.Policy.Engine.Services;
internal sealed class PolicyEvaluationService
{
private readonly PolicyEvaluator evaluator = new();
public PolicyEvaluationResult Evaluate(PolicyIrDocument document, PolicyEvaluationContext context)
{
if (document is null)
{
using System.Collections.Immutable;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Evaluation;
namespace StellaOps.Policy.Engine.Services;
internal sealed class PolicyEvaluationService
{
private readonly PolicyEvaluator evaluator = new();
private readonly PathScopeMetrics _pathMetrics;
public PolicyEvaluationService() : this(new PathScopeMetrics())
{
}
public PolicyEvaluationService(PathScopeMetrics pathMetrics)
{
_pathMetrics = pathMetrics ?? throw new ArgumentNullException(nameof(pathMetrics));
}
public PolicyEvaluationResult Evaluate(PolicyIrDocument document, PolicyEvaluationContext context)
{
if (document is null)
{
throw new ArgumentNullException(nameof(document));
}
@@ -19,8 +29,10 @@ internal sealed class PolicyEvaluationService
{
throw new ArgumentNullException(nameof(context));
}
var request = new PolicyEvaluationRequest(document, context);
return evaluator.Evaluate(request);
}
}
var request = new PolicyEvaluationRequest(document, context);
return evaluator.Evaluate(request);
}
// PathScopeSimulationService partial class relies on _pathMetrics.
}

View File

@@ -0,0 +1,96 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Streaming;
/// <summary>
/// Request contract for POLICY-ENGINE-29-002/003 streaming simulations.
/// </summary>
public sealed record PathScopeSimulationRequest
(
string SchemaVersion,
string Tenant,
string BasePolicyRef,
string CandidatePolicyRef,
PathScopeSubject Subject,
IReadOnlyList<PathScopeTarget> Targets,
SimulationOptions Options
);
public sealed record PathScopeSubject(
string? Purl,
string? Cpe,
string? PackagePath,
string? OsImage
)
{
public bool HasCoordinates => !string.IsNullOrWhiteSpace(Purl) || !string.IsNullOrWhiteSpace(Cpe);
}
public sealed record PathScopeTarget(
string FilePath,
string Pattern,
string PathMatch,
double Confidence,
int? DepthLimit,
string? Digest,
string? TreeDigest,
string? EvidenceHash,
DateTimeOffset? IngestedAt,
string? ConnectorId
);
public sealed record SimulationOptions(
string Sort,
int? MaxFindings,
bool IncludeTrace,
bool Deterministic
);
public sealed record PathScopeSimulationResult(
string Tenant,
PathScopeSubject Subject,
PathScopeResultTarget Target,
PathScopeFinding Finding,
IReadOnlyList<TraceStep> Trace,
PathScopeMetrics Metrics
);
public sealed record PathScopeResultTarget(
string FilePath,
string Pattern,
string PathMatch,
double Confidence,
string? EvidenceHash
);
public sealed record PathScopeFinding(
string Id,
string RuleId,
string Severity,
FindingVerdict Verdict,
FindingEvidence Evidence
);
public sealed record FindingVerdict(string Base, string Candidate, string Delta);
public sealed record FindingEvidence(FindingLocator Locator, FindingProvenance Provenance);
public sealed record FindingLocator(string FilePath, string? Digest);
public sealed record FindingProvenance(DateTimeOffset? IngestedAt, string? ConnectorId);
public sealed record TraceStep(string Step, string? Rule, string? Path, string? Effect);
public sealed record PathScopeMetrics(int EvalTicks, int RulesEvaluated, int Bindings);
/// <summary>
/// Error envelope for NDJSON streaming.
/// </summary>
public sealed record PathScopeSimulationError(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("message")] string Message
)
{
public static PathScopeSimulationError Schema(string message) => new("error", "POLICY_29_002_SCHEMA", message);
}

View File

@@ -0,0 +1,108 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using StellaOps.Policy.Engine.Services;
namespace StellaOps.Policy.Engine.Streaming;
/// <summary>
/// Minimal, deterministic implementation of path/scope-aware streaming simulation (POLICY-ENGINE-29-003).
/// Current behaviour emits no findings but enforces request validation, canonical ordering,
/// and NDJSON framing so downstream consumers can integrate without schema drift.
/// </summary>
public sealed class PathScopeSimulationService
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly PolicyEvaluationService _evaluationService;
public PathScopeSimulationService(PolicyEvaluationService evaluationService)
{
_evaluationService = evaluationService ?? throw new ArgumentNullException(nameof(evaluationService));
}
public IAsyncEnumerable<string> StreamAsync(PathScopeSimulationRequest request, CancellationToken ct = default)
{
ValidateRequest(request);
var orderedTargets = request.Targets
.OrderBy(t => t.FilePath, StringComparer.Ordinal)
.ThenBy(t => t.Pattern, StringComparer.Ordinal)
.ThenByDescending(t => t.Confidence)
.ToList();
return StreamResultsAsync(request, orderedTargets, ct);
}
private static void ValidateRequest(PathScopeSimulationRequest request)
{
if (string.IsNullOrWhiteSpace(request.SchemaVersion))
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("schemaVersion is required"));
}
if (request.Targets is null || request.Targets.Count == 0)
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("At least one target is required"));
}
if (!request.Subject.HasCoordinates)
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("subject.purl or subject.cpe is required"));
}
foreach (var target in request.Targets)
{
if (string.IsNullOrWhiteSpace(target.FilePath))
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("target.filePath is required"));
}
if (string.IsNullOrWhiteSpace(target.Pattern))
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("target.pattern is required"));
}
if (string.IsNullOrWhiteSpace(target.PathMatch))
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("target.pathMatch is required"));
}
}
if (!request.Options.Deterministic)
{
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("options.deterministic must be true"));
}
}
private async IAsyncEnumerable<string> StreamResultsAsync(
PathScopeSimulationRequest request,
IReadOnlyList<PathScopeTarget> orderedTargets,
[EnumeratorCancellation] CancellationToken ct)
{
foreach (var target in orderedTargets)
{
ct.ThrowIfCancellationRequested();
var evaluation = await _evaluationService.EvaluatePathScopeAsync(
request, target, ct).ConfigureAwait(false);
yield return evaluation.ToJsonString(SerializerOptions);
await Task.Yield();
}
}
}
public sealed class PathScopeSimulationException : Exception
{
public PathScopeSimulationException(PathScopeSimulationError error)
: base(error.Message)
{
Error = error;
}
public PathScopeSimulationError Error { get; }
}

View File

@@ -0,0 +1,49 @@
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Policy.Engine.Streaming;
namespace StellaOps.Policy.Engine.Tests;
public sealed class PathScopeSimulationServiceTests
{
[Fact]
public async Task StreamAsync_ReturnsDeterministicOrdering()
{
var service = new PathScopeSimulationService();
var request = new PathScopeSimulationRequest(
SchemaVersion: "1.0.0",
Tenant: "acme",
BasePolicyRef: "policy://acme/main@sha256:1",
CandidatePolicyRef: "policy://acme/feat@sha256:2",
Subject: new PathScopeSubject("pkg:npm/lodash@4.17.21", null, "lib.js", null),
Targets: new[]
{
new PathScopeTarget("b/file.js", "b/", "prefix", 0.5, null, null, null, "e2", null, null),
new PathScopeTarget("a/file.js", "a/", "prefix", 0.9, null, null, null, "e1", null, null)
},
Options: new SimulationOptions("path,finding,verdict", 100, IncludeTrace: true, Deterministic: true));
var lines = await service.StreamAsync(request).ToListAsync();
Assert.Equal(2, lines.Count);
Assert.Contains(lines[0], s => s.Contains("\"filePath\":\"a/file.js\""));
Assert.Contains(lines[1], s => s.Contains("\"filePath\":\"b/file.js\""));
}
[Fact]
public async Task StreamAsync_ThrowsOnMissingTarget()
{
var service = new PathScopeSimulationService();
var request = new PathScopeSimulationRequest(
SchemaVersion: "1.0.0",
Tenant: "acme",
BasePolicyRef: "policy://acme/main@sha256:1",
CandidatePolicyRef: "policy://acme/feat@sha256:2",
Subject: new PathScopeSubject("pkg:npm/lodash@4.17.21", null, null, null),
Targets: Array.Empty<PathScopeTarget>(),
Options: new SimulationOptions("path,finding,verdict", 100, IncludeTrace: true, Deterministic: true));
await Assert.ThrowsAsync<PathScopeSimulationException>(() => service.StreamAsync(request).ToListAsync());
}
}