Files
git.stella-ops.org/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Program.cs

962 lines
35 KiB
C#

using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Caching;
using StellaOps.AdvisoryAI.Diagnostics;
using StellaOps.AdvisoryAI.Explanation;
using StellaOps.AdvisoryAI.Hosting;
using StellaOps.AdvisoryAI.Metrics;
using StellaOps.AdvisoryAI.Outputs;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Queue;
using StellaOps.AdvisoryAI.PolicyStudio;
using StellaOps.AdvisoryAI.Remediation;
using StellaOps.AdvisoryAI.WebService.Contracts;
using StellaOps.AdvisoryAI.WebService.Services;
using StellaOps.Router.AspNet;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "ADVISORYAI__");
builder.Services.AddAdvisoryAiCore(builder.Configuration);
// Authorization service
builder.Services.AddSingleton<StellaOps.AdvisoryAI.WebService.Services.IAuthorizationService, StellaOps.AdvisoryAI.WebService.Services.HeaderBasedAuthorizationService>();
// Rate limits service with configuration
builder.Services.AddOptions<StellaOps.AdvisoryAI.WebService.Services.RateLimitsOptions>()
.Bind(builder.Configuration.GetSection(StellaOps.AdvisoryAI.WebService.Services.RateLimitsOptions.SectionName))
.ValidateOnStart();
builder.Services.AddSingleton<StellaOps.AdvisoryAI.WebService.Services.IRateLimitsService, StellaOps.AdvisoryAI.WebService.Services.ConfigDrivenRateLimitsService>();
// TimeProvider for deterministic timestamps
builder.Services.AddSingleton(TimeProvider.System);
// VEX-AI-016: Consent and justification services
builder.Services.AddSingleton<IAiConsentStore, InMemoryAiConsentStore>();
builder.Services.AddSingleton<IAiJustificationGenerator, DefaultAiJustificationGenerator>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();
// Stella Router integration
var routerOptions = builder.Configuration.GetSection("AdvisoryAI:Router").Get<StellaRouterOptionsBase>();
builder.Services.TryAddStellaRouter(
serviceName: "advisoryai",
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
routerOptions: routerOptions);
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("advisory-ai", context =>
{
var clientId = context.Request.Headers.TryGetValue("X-StellaOps-Client", out var value)
? value.ToString()
: "anonymous";
return RateLimitPartition.GetTokenBucketLimiter(
clientId,
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 30,
TokensPerPeriod = 30,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
AutoReplenishment = true
});
});
});
var app = builder.Build();
app.UseExceptionHandler(static options => options.Run(async context =>
{
var problem = Results.Problem(statusCode: StatusCodes.Status500InternalServerError);
await problem.ExecuteAsync(context);
}));
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseRateLimiter();
app.TryUseStellaRouter(routerOptions);
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapPost("/v1/advisory-ai/pipeline/{taskType}", HandleSinglePlan)
.RequireRateLimiting("advisory-ai");
app.MapPost("/v1/advisory-ai/pipeline:batch", HandleBatchPlans)
.RequireRateLimiting("advisory-ai");
app.MapGet("/v1/advisory-ai/outputs/{cacheKey}", HandleGetOutput)
.RequireRateLimiting("advisory-ai");
// Explanation endpoints (SPRINT_20251226_015_AI_zastava_companion)
app.MapPost("/v1/advisory-ai/explain", HandleExplain)
.RequireRateLimiting("advisory-ai");
app.MapGet("/v1/advisory-ai/explain/{explanationId}/replay", HandleExplanationReplay)
.RequireRateLimiting("advisory-ai");
// Remediation endpoints (SPRINT_20251226_016_AI_remedy_autopilot)
app.MapPost("/v1/advisory-ai/remediation/plan", HandleRemediationPlan)
.RequireRateLimiting("advisory-ai");
app.MapPost("/v1/advisory-ai/remediation/apply", HandleApplyRemediation)
.RequireRateLimiting("advisory-ai");
app.MapGet("/v1/advisory-ai/remediation/status/{prId}", HandleRemediationStatus)
.RequireRateLimiting("advisory-ai");
// Policy Studio endpoints (SPRINT_20251226_017_AI_policy_copilot)
app.MapPost("/v1/advisory-ai/policy/studio/parse", HandlePolicyParse)
.RequireRateLimiting("advisory-ai");
app.MapPost("/v1/advisory-ai/policy/studio/generate", HandlePolicyGenerate)
.RequireRateLimiting("advisory-ai");
app.MapPost("/v1/advisory-ai/policy/studio/validate", HandlePolicyValidate)
.RequireRateLimiting("advisory-ai");
app.MapPost("/v1/advisory-ai/policy/studio/compile", HandlePolicyCompile)
.RequireRateLimiting("advisory-ai");
// VEX-AI-016: Consent endpoints
app.MapGet("/v1/advisory-ai/consent", HandleGetConsent)
.RequireRateLimiting("advisory-ai");
app.MapPost("/v1/advisory-ai/consent", HandleGrantConsent)
.RequireRateLimiting("advisory-ai");
app.MapDelete("/v1/advisory-ai/consent", HandleRevokeConsent)
.RequireRateLimiting("advisory-ai");
// VEX-AI-016: Justification endpoint
app.MapPost("/v1/advisory-ai/justify", HandleJustify)
.RequireRateLimiting("advisory-ai");
// VEX-AI-016: Remediate alias (maps to remediation/plan)
app.MapPost("/v1/advisory-ai/remediate", HandleRemediate)
.RequireRateLimiting("advisory-ai");
// VEX-AI-016: Rate limits endpoint
app.MapGet("/v1/advisory-ai/rate-limits", HandleGetRateLimits)
.RequireRateLimiting("advisory-ai");
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);
app.Run();
static async Task<IResult> HandleSinglePlan(
HttpContext httpContext,
string taskType,
PipelinePlanRequest request,
IAdvisoryPipelineOrchestrator orchestrator,
IAdvisoryPlanCache planCache,
IAdvisoryTaskQueue taskQueue,
AdvisoryAiMetrics requestMetrics,
AdvisoryPipelineMetrics pipelineMetrics,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.plan_request", ActivityKind.Server);
activity?.SetTag("advisory.task_type", taskType);
activity?.SetTag("advisory.advisory_key", request.AdvisoryKey);
if (!Enum.TryParse<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedType))
{
return Results.BadRequest(new { error = $"Unknown task type '{taskType}'." });
}
if (!EnsureAuthorized(httpContext, parsedType))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
if (string.IsNullOrWhiteSpace(request.AdvisoryKey))
{
return Results.BadRequest(new { error = "AdvisoryKey is required." });
}
var normalizedRequest = request with { TaskType = parsedType };
var taskRequest = normalizedRequest.ToTaskRequest();
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.plan_cache_key", plan.CacheKey);
await planCache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
await taskQueue.EnqueueAsync(new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request), cancellationToken).ConfigureAwait(false);
requestMetrics.RecordRequest(plan.Request.TaskType.ToString());
requestMetrics.RecordEnqueued(plan.Request.TaskType.ToString());
pipelineMetrics.RecordPlanQueued(plan.Request.TaskType);
var response = AdvisoryPipelinePlanResponse.FromPlan(plan);
return Results.Ok(response);
}
static async Task<IResult> HandleBatchPlans(
HttpContext httpContext,
BatchPipelinePlanRequest batchRequest,
IAdvisoryPipelineOrchestrator orchestrator,
IAdvisoryPlanCache planCache,
IAdvisoryTaskQueue taskQueue,
AdvisoryAiMetrics requestMetrics,
AdvisoryPipelineMetrics pipelineMetrics,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.plan_batch", ActivityKind.Server);
activity?.SetTag("advisory.batch_size", batchRequest.Requests.Count);
if (batchRequest.Requests.Count == 0)
{
return Results.BadRequest(new { error = "At least one request must be supplied." });
}
var results = new List<AdvisoryPipelinePlanResponse>(batchRequest.Requests.Count);
foreach (var item in batchRequest.Requests)
{
var taskType = item.TaskType?.ToString() ?? "summary";
if (!Enum.TryParse<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedType))
{
return Results.BadRequest(new { error = $"Unknown task type '{taskType}' in batch item." });
}
if (!EnsureAuthorized(httpContext, parsedType))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
if (string.IsNullOrWhiteSpace(item.AdvisoryKey))
{
return Results.BadRequest(new { error = "AdvisoryKey is required for every batch item." });
}
var normalizedRequest = item with { TaskType = parsedType };
var taskRequest = normalizedRequest.ToTaskRequest();
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
activity?.AddEvent(new ActivityEvent("advisory.plan.created", tags: new ActivityTagsCollection
{
{ "advisory.task_type", plan.Request.TaskType.ToString() },
{ "advisory.advisory_key", plan.Request.AdvisoryKey },
{ "advisory.plan_cache_key", plan.CacheKey }
}));
await planCache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
await taskQueue.EnqueueAsync(new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request), cancellationToken).ConfigureAwait(false);
requestMetrics.RecordRequest(plan.Request.TaskType.ToString());
requestMetrics.RecordEnqueued(plan.Request.TaskType.ToString());
pipelineMetrics.RecordPlanQueued(plan.Request.TaskType);
results.Add(AdvisoryPipelinePlanResponse.FromPlan(plan));
}
return Results.Ok(results);
}
static async Task<IResult> HandleGetOutput(
HttpContext httpContext,
string cacheKey,
string taskType,
string? profile,
IAdvisoryOutputStore outputStore,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(outputStore);
if (!Enum.TryParse<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedTaskType))
{
return Results.BadRequest(new { error = $"Unknown task type '{taskType}'." });
}
if (!EnsureAuthorized(httpContext, parsedTaskType))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var resolvedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile!.Trim();
var output = await outputStore.TryGetAsync(cacheKey, parsedTaskType, resolvedProfile, cancellationToken)
.ConfigureAwait(false);
if (output is null)
{
return Results.NotFound(new { error = "Output not found." });
}
return Results.Ok(AdvisoryOutputResponse.FromDomain(output));
}
static bool EnsureAuthorized(HttpContext context, AdvisoryTaskType taskType)
{
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
{
return false;
}
var allowed = scopes
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (allowed.Contains("advisory:run"))
{
return true;
}
return allowed.Contains($"advisory:{taskType.ToString().ToLowerInvariant()}");
}
static bool EnsureExplainAuthorized(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
{
return false;
}
var allowed = scopes
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return allowed.Contains("advisory:run") || allowed.Contains("advisory:explain");
}
// ZASTAVA-13: POST /v1/advisory-ai/explain
static async Task<IResult> HandleExplain(
HttpContext httpContext,
ExplainRequest request,
IExplanationGenerator explanationGenerator,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.explain", ActivityKind.Server);
activity?.SetTag("advisory.finding_id", request.FindingId);
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
activity?.SetTag("advisory.explanation_type", request.ExplanationType);
if (!EnsureExplainAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
try
{
var domainRequest = request.ToDomain();
var result = await explanationGenerator.GenerateAsync(domainRequest, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.explanation_id", result.ExplanationId);
activity?.SetTag("advisory.authority", result.Authority.ToString());
activity?.SetTag("advisory.citation_rate", result.CitationRate);
return Results.Ok(ExplainResponse.FromDomain(result));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// ZASTAVA-14: GET /v1/advisory-ai/explain/{explanationId}/replay
static async Task<IResult> HandleExplanationReplay(
HttpContext httpContext,
string explanationId,
IExplanationGenerator explanationGenerator,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.explain_replay", ActivityKind.Server);
activity?.SetTag("advisory.explanation_id", explanationId);
if (!EnsureExplainAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
try
{
var result = await explanationGenerator.ReplayAsync(explanationId, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.replayed_explanation_id", result.ExplanationId);
activity?.SetTag("advisory.authority", result.Authority.ToString());
return Results.Ok(ExplainResponse.FromDomain(result));
}
catch (InvalidOperationException ex)
{
return Results.NotFound(new { error = ex.Message });
}
}
static bool EnsureRemediationAuthorized(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
{
return false;
}
var allowed = scopes
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return allowed.Contains("advisory:run") || allowed.Contains("advisory:remediate");
}
// REMEDY-19: POST /v1/advisory-ai/remediation/plan
static async Task<IResult> HandleRemediationPlan(
HttpContext httpContext,
RemediationPlanApiRequest request,
IRemediationPlanner remediationPlanner,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediation_plan", ActivityKind.Server);
activity?.SetTag("advisory.finding_id", request.FindingId);
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
activity?.SetTag("advisory.remediation_type", request.RemediationType);
if (!EnsureRemediationAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
try
{
var domainRequest = request.ToDomain();
var plan = await remediationPlanner.GeneratePlanAsync(domainRequest, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.plan_id", plan.PlanId);
activity?.SetTag("advisory.risk_assessment", plan.RiskAssessment.ToString());
activity?.SetTag("advisory.pr_ready", plan.PrReady);
return Results.Ok(RemediationPlanApiResponse.FromDomain(plan));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// REMEDY-20: POST /v1/advisory-ai/remediation/apply
static async Task<IResult> HandleApplyRemediation(
HttpContext httpContext,
ApplyRemediationRequest request,
IRemediationPlanner remediationPlanner,
IEnumerable<IPullRequestGenerator> prGenerators,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.apply_remediation", ActivityKind.Server);
activity?.SetTag("advisory.plan_id", request.PlanId);
activity?.SetTag("advisory.scm_type", request.ScmType);
if (!EnsureRemediationAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var plan = await remediationPlanner.GetPlanAsync(request.PlanId, cancellationToken).ConfigureAwait(false);
if (plan is null)
{
return Results.NotFound(new { error = $"Plan {request.PlanId} not found" });
}
var generator = prGenerators.FirstOrDefault(g => g.ScmType.Equals(request.ScmType, StringComparison.OrdinalIgnoreCase));
if (generator is null)
{
return Results.BadRequest(new { error = $"SCM type '{request.ScmType}' not supported" });
}
try
{
var prResult = await generator.CreatePullRequestAsync(plan, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.pr_id", prResult.PrId);
activity?.SetTag("advisory.pr_status", prResult.Status.ToString());
return Results.Ok(PullRequestApiResponse.FromDomain(prResult));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// REMEDY-21: GET /v1/advisory-ai/remediation/status/{prId}
static async Task<IResult> HandleRemediationStatus(
HttpContext httpContext,
string prId,
string? scmType,
IEnumerable<IPullRequestGenerator> prGenerators,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediation_status", ActivityKind.Server);
activity?.SetTag("advisory.pr_id", prId);
if (!EnsureRemediationAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var resolvedScmType = scmType ?? "github";
var generator = prGenerators.FirstOrDefault(g => g.ScmType.Equals(resolvedScmType, StringComparison.OrdinalIgnoreCase));
if (generator is null)
{
return Results.BadRequest(new { error = $"SCM type '{resolvedScmType}' not supported" });
}
try
{
var prResult = await generator.GetStatusAsync(prId, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.pr_status", prResult.Status.ToString());
return Results.Ok(PullRequestApiResponse.FromDomain(prResult));
}
catch (InvalidOperationException ex)
{
return Results.NotFound(new { error = ex.Message });
}
}
static bool EnsurePolicyAuthorized(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
{
return false;
}
var allowed = scopes
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return allowed.Contains("advisory:run") || allowed.Contains("policy:write");
}
// POLICY-16: POST /v1/advisory-ai/policy/studio/parse
static async Task<IResult> HandlePolicyParse(
HttpContext httpContext,
PolicyParseApiRequest request,
IPolicyIntentParser intentParser,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.policy_parse", ActivityKind.Server);
activity?.SetTag("advisory.input_length", request.Input.Length);
if (!EnsurePolicyAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
try
{
var result = await intentParser.ParseAsync(request.Input, request.ToContext(), cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.intent_id", result.Intent.IntentId);
activity?.SetTag("advisory.confidence", result.Intent.Confidence);
return Results.Ok(PolicyParseApiResponse.FromDomain(result));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// POLICY-17: POST /v1/advisory-ai/policy/studio/generate
static async Task<IResult> HandlePolicyGenerate(
HttpContext httpContext,
PolicyGenerateApiRequest request,
IPolicyIntentStore intentStore,
IPolicyRuleGenerator ruleGenerator,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.policy_generate", ActivityKind.Server);
activity?.SetTag("advisory.intent_id", request.IntentId);
if (!EnsurePolicyAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var intent = await intentStore.GetAsync(request.IntentId, cancellationToken).ConfigureAwait(false);
if (intent is null)
{
return Results.NotFound(new { error = $"Intent {request.IntentId} not found" });
}
try
{
var result = await ruleGenerator.GenerateAsync(intent, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.rule_count", result.Rules.Count);
return Results.Ok(RuleGenerationApiResponse.FromDomain(result));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// POLICY-18: POST /v1/advisory-ai/policy/studio/validate
static async Task<IResult> HandlePolicyValidate(
HttpContext httpContext,
PolicyValidateApiRequest request,
IPolicyRuleGenerator ruleGenerator,
ITestCaseSynthesizer testSynthesizer,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.policy_validate", ActivityKind.Server);
activity?.SetTag("advisory.rule_count", request.RuleIds.Count);
if (!EnsurePolicyAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
// In a real implementation, we would fetch rules from storage
// For now, return a mock validation result
var validation = new RuleValidationResult
{
Valid = true,
Conflicts = Array.Empty<RuleConflict>(),
UnreachableConditions = Array.Empty<string>(),
PotentialLoops = Array.Empty<string>(),
Coverage = 0.85
};
return Results.Ok(new ValidationApiResponse
{
Valid = validation.Valid,
Conflicts = validation.Conflicts.Select(c => new RuleConflictApiResponse
{
RuleId1 = c.RuleId1,
RuleId2 = c.RuleId2,
Description = c.Description,
SuggestedResolution = c.SuggestedResolution,
Severity = c.Severity
}).ToList(),
UnreachableConditions = validation.UnreachableConditions.ToList(),
PotentialLoops = validation.PotentialLoops.ToList(),
Coverage = validation.Coverage,
TestCases = Array.Empty<PolicyTestCaseApiResponse>(),
TestResults = null
});
}
// POLICY-19: POST /v1/advisory-ai/policy/studio/compile
// NOTE: This is a stub implementation. In production, this would compile rules into a PolicyBundle.
// The stub returns experimental markers to indicate incomplete implementation.
static Task<IResult> HandlePolicyCompile(
HttpContext httpContext,
PolicyCompileApiRequest request,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.policy_compile", ActivityKind.Server);
activity?.SetTag("advisory.bundle_name", request.BundleName);
activity?.SetTag("advisory.rule_count", request.RuleIds.Count);
if (!EnsurePolicyAuthorized(httpContext))
{
return Task.FromResult(Results.StatusCode(StatusCodes.Status403Forbidden));
}
// STUB: This endpoint is experimental and not wired to real policy compilation.
// Return a deterministic bundle ID derived from input to avoid nondeterministic output.
var inputHash = ComputeDeterministicBundleId(request.BundleName, request.RuleIds);
var bundleId = $"bundle:stub:{inputHash}";
var now = timeProvider.GetUtcNow();
// Compute content hash deterministically from the rule IDs
var contentHash = ComputeDeterministicContentHash(request.RuleIds);
var response = new PolicyBundleApiResponse
{
BundleId = bundleId,
BundleName = request.BundleName,
Version = "1.0.0",
RuleCount = request.RuleIds.Count,
CompiledAt = now.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
ContentHash = $"sha256:{contentHash}",
SignatureId = null // Would be signed in production
};
return Task.FromResult(Results.Ok(response));
}
// Deterministic hash computation for stub bundle ID
static string ComputeDeterministicBundleId(string bundleName, IReadOnlyList<string> ruleIds)
{
var input = $"{bundleName}:{string.Join(",", ruleIds.OrderBy(x => x, StringComparer.Ordinal))}";
var bytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes)[..32].ToLowerInvariant();
}
// Deterministic content hash for stub bundles
static string ComputeDeterministicContentHash(IReadOnlyList<string> ruleIds)
{
var input = string.Join(",", ruleIds.OrderBy(x => x, StringComparer.Ordinal));
var bytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
// VEX-AI-016: Consent handler functions
static string GetTenantId(HttpContext context)
{
return context.Request.Headers.TryGetValue("X-StellaOps-Tenant", out var value)
? value.ToString()
: "default";
}
static string GetUserId(HttpContext context)
{
return context.Request.Headers.TryGetValue("X-StellaOps-User", out var value)
? value.ToString()
: "anonymous";
}
static async Task<IResult> HandleGetConsent(
HttpContext httpContext,
IAiConsentStore consentStore,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
var userId = GetUserId(httpContext);
var record = await consentStore.GetConsentAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.Ok(new AiConsentStatusResponse
{
Consented = false,
Scope = "all",
SessionLevel = false
});
}
return Results.Ok(new AiConsentStatusResponse
{
Consented = record.Consented,
ConsentedAt = record.ConsentedAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
ConsentedBy = record.UserId,
Scope = record.Scope,
ExpiresAt = record.ExpiresAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
SessionLevel = record.SessionLevel
});
}
static async Task<IResult> HandleGrantConsent(
HttpContext httpContext,
AiConsentGrantRequest request,
IAiConsentStore consentStore,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (!request.DataShareAcknowledged)
{
return Results.BadRequest(new { error = "Data sharing acknowledgement is required" });
}
var tenantId = GetTenantId(httpContext);
var userId = GetUserId(httpContext);
var grant = new AiConsentGrant
{
Scope = request.Scope,
SessionLevel = request.SessionLevel,
DataShareAcknowledged = request.DataShareAcknowledged,
Duration = request.SessionLevel ? TimeSpan.FromHours(24) : null
};
var record = await consentStore.GrantConsentAsync(tenantId, userId, grant, cancellationToken).ConfigureAwait(false);
return Results.Ok(new AiConsentGrantResponse
{
Consented = record.Consented,
ConsentedAt = record.ConsentedAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? timeProvider.GetUtcNow().ToString("O", System.Globalization.CultureInfo.InvariantCulture),
ExpiresAt = record.ExpiresAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture)
});
}
static async Task<IResult> HandleRevokeConsent(
HttpContext httpContext,
IAiConsentStore consentStore,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
var userId = GetUserId(httpContext);
await consentStore.RevokeConsentAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
return Results.NoContent();
}
// VEX-AI-016: Justification handler
static bool EnsureJustifyAuthorized(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
{
return false;
}
var allowed = scopes
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return allowed.Contains("advisory:run") || allowed.Contains("advisory:justify");
}
static async Task<IResult> HandleJustify(
HttpContext httpContext,
AiJustifyApiRequest request,
IAiJustificationGenerator justificationGenerator,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.justify", System.Diagnostics.ActivityKind.Server);
activity?.SetTag("advisory.cve_id", request.CveId);
activity?.SetTag("advisory.product_ref", request.ProductRef);
activity?.SetTag("advisory.proposed_status", request.ProposedStatus);
if (!EnsureJustifyAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var domainRequest = new AiJustificationRequest
{
CveId = request.CveId,
ProductRef = request.ProductRef,
ProposedStatus = request.ProposedStatus,
JustificationType = request.JustificationType,
ReachabilityScore = request.ContextData?.ReachabilityScore,
CodeSearchResults = request.ContextData?.CodeSearchResults,
SbomContext = request.ContextData?.SbomContext,
CallGraphSummary = request.ContextData?.CallGraphSummary,
RelatedVexStatements = request.ContextData?.RelatedVexStatements,
CorrelationId = request.CorrelationId
};
try
{
var result = await justificationGenerator.GenerateAsync(domainRequest, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.justification_id", result.JustificationId);
activity?.SetTag("advisory.confidence", result.ConfidenceScore);
return Results.Ok(new AiJustifyApiResponse
{
JustificationId = result.JustificationId,
DraftJustification = result.DraftJustification,
SuggestedJustificationType = result.SuggestedJustificationType,
ConfidenceScore = result.ConfidenceScore,
EvidenceSuggestions = result.EvidenceSuggestions,
ModelVersion = result.ModelVersion,
GeneratedAt = result.GeneratedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
TraceId = result.TraceId
});
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// VEX-AI-016: Remediate alias (delegates to remediation/plan)
static async Task<IResult> HandleRemediate(
HttpContext httpContext,
RemediationPlanApiRequest request,
IRemediationPlanner remediationPlanner,
CancellationToken cancellationToken)
{
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediate", System.Diagnostics.ActivityKind.Server);
activity?.SetTag("advisory.finding_id", request.FindingId);
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
if (!EnsureRemediationAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
try
{
var domainRequest = request.ToDomain();
var plan = await remediationPlanner.GeneratePlanAsync(domainRequest, cancellationToken).ConfigureAwait(false);
activity?.SetTag("advisory.plan_id", plan.PlanId);
activity?.SetTag("advisory.risk_assessment", plan.RiskAssessment.ToString());
return Results.Ok(RemediationPlanApiResponse.FromDomain(plan));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
// VEX-AI-016: Rate limits handler using config-driven service
static Task<IResult> HandleGetRateLimits(
HttpContext httpContext,
StellaOps.AdvisoryAI.WebService.Services.IRateLimitsService rateLimitsService,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var limits = rateLimitsService.GetRateLimits(timeProvider);
var response = limits.Select(l => new AiRateLimitInfoResponse
{
Feature = l.Feature,
Limit = l.Limit,
Remaining = l.Remaining,
ResetsAt = l.ResetsAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture)
}).ToList();
return Task.FromResult(Results.Ok(response));
}
internal sealed record PipelinePlanRequest(
AdvisoryTaskType? TaskType,
string AdvisoryKey,
string? ArtifactId,
string? ArtifactPurl,
string? PolicyVersion,
string Profile = "default",
IReadOnlyCollection<string>? PreferredSections = null,
bool ForceRefresh = false)
{
public AdvisoryTaskRequest ToTaskRequest()
{
if (TaskType is null)
{
throw new InvalidOperationException("Task type must be specified.");
}
return new AdvisoryTaskRequest(
TaskType.Value,
AdvisoryKey,
ArtifactId,
ArtifactPurl,
PolicyVersion,
Profile,
PreferredSections,
ForceRefresh);
}
}
internal sealed record BatchPipelinePlanRequest
{
public IReadOnlyList<PipelinePlanRequest> Requests { get; init; } = Array.Empty<PipelinePlanRequest>();
}