using System.Collections.Immutable; using System.Diagnostics; using System.Linq; using System.Net; using System.Runtime.CompilerServices; 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.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using StellaOps.AdvisoryAI.Attestation; using StellaOps.AdvisoryAI.Caching; using StellaOps.AdvisoryAI.Chat; using StellaOps.Evidence.Pack; using StellaOps.AdvisoryAI.Diagnostics; using StellaOps.AdvisoryAI.Evidence; 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.Endpoints; 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); builder.Services.AddAdvisoryChat(builder.Configuration); // Authorization service builder.Services.AddSingleton(); // Rate limits service with configuration builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(StellaOps.AdvisoryAI.WebService.Services.RateLimitsOptions.SectionName)) .ValidateOnStart(); builder.Services.AddSingleton(); // TimeProvider for deterministic timestamps builder.Services.AddSingleton(TimeProvider.System); // VEX-AI-016: Consent and justification services builder.Services.AddSingleton(); builder.Services.AddSingleton(); // AI Attestations (Sprint: SPRINT_20260109_011_001 Task: AIAT-009) builder.Services.AddAiAttestationServices(); builder.Services.AddInMemoryAiAttestationStore(); // Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-010) builder.Services.AddEvidencePack(); builder.Services.TryAddSingleton(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApi(); builder.Services.AddProblemDetails(); // Stella Router integration var routerOptions = builder.Configuration.GetSection("AdvisoryAI:Router").Get(); 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"); // Chat endpoints (SPRINT_20260107_006_003 CH-005) app.MapPost("/v1/advisory-ai/conversations", HandleCreateConversation) .RequireRateLimiting("advisory-ai"); app.MapGet("/v1/advisory-ai/conversations/{conversationId}", HandleGetConversation) .RequireRateLimiting("advisory-ai"); app.MapPost("/v1/advisory-ai/conversations/{conversationId}/turns", HandleAddTurn) .RequireRateLimiting("advisory-ai"); app.MapDelete("/v1/advisory-ai/conversations/{conversationId}", HandleDeleteConversation) .RequireRateLimiting("advisory-ai"); app.MapGet("/v1/advisory-ai/conversations", HandleListConversations) .RequireRateLimiting("advisory-ai"); // Chat gateway endpoints (controlled conversational interface) app.MapChatEndpoints(); // AI Attestations endpoints (Sprint: SPRINT_20260109_011_001 Task: AIAT-009) app.MapAttestationEndpoints(); // Evidence Pack endpoints (Sprint: SPRINT_20260109_011_005 Task: EVPK-010) app.MapEvidencePackEndpoints(); // Refresh Router endpoint cache app.TryRefreshStellaRouterEndpoints(routerOptions); app.Run(); static async Task 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(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 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(batchRequest.Requests.Count); foreach (var item in batchRequest.Requests) { var taskType = item.TaskType?.ToString() ?? "summary"; if (!Enum.TryParse(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 HandleGetOutput( HttpContext httpContext, string cacheKey, string taskType, string? profile, IAdvisoryOutputStore outputStore, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(outputStore); if (!Enum.TryParse(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 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 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 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 HandleApplyRemediation( HttpContext httpContext, ApplyRemediationRequest request, IRemediationPlanner remediationPlanner, IEnumerable 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 HandleRemediationStatus( HttpContext httpContext, string prId, string? scmType, IEnumerable 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 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 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 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(), UnreachableConditions = Array.Empty(), PotentialLoops = Array.Empty(), 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(), 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 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 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 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 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 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 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 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 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 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)); } // Chat endpoint handlers (SPRINT_20260107_006_003 CH-005) static async Task HandleCreateConversation( HttpContext httpContext, StellaOps.AdvisoryAI.WebService.Contracts.CreateConversationRequest request, IConversationService conversationService, CancellationToken cancellationToken) { using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.create_conversation", ActivityKind.Server); activity?.SetTag("advisory.tenant_id", request.TenantId); if (!EnsureChatAuthorized(httpContext)) { return Results.StatusCode(StatusCodes.Status403Forbidden); } // Get user ID from header var userId = httpContext.Request.Headers.TryGetValue("X-StellaOps-User", out var userHeader) ? userHeader.ToString() : "anonymous"; var conversationRequest = new ConversationRequest { TenantId = request.TenantId, UserId = userId, InitialContext = request.Context is not null ? new ConversationContext { CurrentCveId = request.Context.CurrentCveId, CurrentComponent = request.Context.CurrentComponent, CurrentImageDigest = request.Context.CurrentImageDigest, ScanId = request.Context.ScanId, SbomId = request.Context.SbomId } : null, Metadata = request.Metadata?.ToImmutableDictionary() }; var conversation = await conversationService.CreateAsync(conversationRequest, cancellationToken).ConfigureAwait(false); activity?.SetTag("advisory.conversation_id", conversation.ConversationId); return Results.Created( $"/v1/advisory-ai/conversations/{conversation.ConversationId}", StellaOps.AdvisoryAI.WebService.Contracts.ConversationResponse.FromConversation(conversation)); } static async Task HandleGetConversation( HttpContext httpContext, string conversationId, IConversationService conversationService, CancellationToken cancellationToken) { using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.get_conversation", ActivityKind.Server); activity?.SetTag("advisory.conversation_id", conversationId); if (!EnsureChatAuthorized(httpContext)) { return Results.StatusCode(StatusCodes.Status403Forbidden); } var conversation = await conversationService.GetAsync(conversationId, cancellationToken).ConfigureAwait(false); if (conversation is null) { return Results.NotFound(new { error = $"Conversation '{conversationId}' not found" }); } return Results.Ok(StellaOps.AdvisoryAI.WebService.Contracts.ConversationResponse.FromConversation(conversation)); } static async Task HandleAddTurn( HttpContext httpContext, string conversationId, StellaOps.AdvisoryAI.WebService.Contracts.AddTurnRequest request, IConversationService conversationService, ChatPromptAssembler? promptAssembler, ChatResponseStreamer? responseStreamer, GroundingValidator? groundingValidator, ActionProposalParser? actionParser, TimeProvider timeProvider, ILogger logger, CancellationToken cancellationToken) { using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.add_turn", ActivityKind.Server); activity?.SetTag("advisory.conversation_id", conversationId); activity?.SetTag("advisory.stream", request.Stream); if (!EnsureChatAuthorized(httpContext)) { return Results.StatusCode(StatusCodes.Status403Forbidden); } var startTime = timeProvider.GetUtcNow(); // Add user turn try { var userTurnRequest = new TurnRequest { Role = TurnRole.User, Content = request.Content, Metadata = request.Metadata?.ToImmutableDictionary() }; var userTurn = await conversationService.AddTurnAsync(conversationId, userTurnRequest, cancellationToken) .ConfigureAwait(false); activity?.SetTag("advisory.user_turn_id", userTurn.TurnId); // For now, return a placeholder response since we don't have the full LLM pipeline // In a complete implementation, this would call the prompt assembler, LLM, and validators var assistantContent = GeneratePlaceholderResponse(request.Content); var assistantTurnRequest = new TurnRequest { Role = TurnRole.Assistant, Content = assistantContent }; var assistantTurn = await conversationService.AddTurnAsync(conversationId, assistantTurnRequest, cancellationToken) .ConfigureAwait(false); var elapsed = timeProvider.GetUtcNow() - startTime; var response = new StellaOps.AdvisoryAI.WebService.Contracts.AssistantTurnResponse { TurnId = assistantTurn.TurnId, Content = assistantTurn.Content, Timestamp = assistantTurn.Timestamp, EvidenceLinks = assistantTurn.EvidenceLinks.IsEmpty ? null : assistantTurn.EvidenceLinks.Select(StellaOps.AdvisoryAI.WebService.Contracts.EvidenceLinkResponse.FromLink).ToList(), ProposedActions = assistantTurn.ProposedActions.IsEmpty ? null : assistantTurn.ProposedActions.Select(StellaOps.AdvisoryAI.WebService.Contracts.ProposedActionResponse.FromAction).ToList(), GroundingScore = 1.0, // Placeholder TokenCount = assistantContent.Split(' ').Length, // Rough estimate DurationMs = (long)elapsed.TotalMilliseconds }; if (request.Stream) { httpContext.Response.ContentType = "text/event-stream"; httpContext.Response.Headers.CacheControl = "no-cache"; httpContext.Response.Headers.Connection = "keep-alive"; if (responseStreamer is null) { await httpContext.Response.WriteAsync( "event: token\n" + $"data: {assistantContent}\n\n", cancellationToken); await httpContext.Response.Body.FlushAsync(cancellationToken); return Results.Empty; } await foreach (var streamEvent in responseStreamer.StreamResponseAsync( StreamPlaceholderTokens(assistantContent, cancellationToken), conversationId, assistantTurn.TurnId, cancellationToken)) { var payload = ChatResponseStreamer.FormatAsSSE(streamEvent); await httpContext.Response.WriteAsync(payload, cancellationToken); await httpContext.Response.Body.FlushAsync(cancellationToken); } return Results.Empty; } return Results.Ok(response); } catch (ConversationNotFoundException) { return Results.NotFound(new { error = $"Conversation '{conversationId}' not found" }); } } static async Task HandleDeleteConversation( HttpContext httpContext, string conversationId, IConversationService conversationService, CancellationToken cancellationToken) { using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.delete_conversation", ActivityKind.Server); activity?.SetTag("advisory.conversation_id", conversationId); if (!EnsureChatAuthorized(httpContext)) { return Results.StatusCode(StatusCodes.Status403Forbidden); } var deleted = await conversationService.DeleteAsync(conversationId, cancellationToken).ConfigureAwait(false); if (!deleted) { return Results.NotFound(new { error = $"Conversation '{conversationId}' not found" }); } return Results.NoContent(); } static async Task HandleListConversations( HttpContext httpContext, string? tenantId, int? limit, IConversationService conversationService, CancellationToken cancellationToken) { using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.list_conversations", ActivityKind.Server); if (!EnsureChatAuthorized(httpContext)) { return Results.StatusCode(StatusCodes.Status403Forbidden); } // Get tenant from header if not provided var effectiveTenantId = tenantId ?? (httpContext.Request.Headers.TryGetValue("X-StellaOps-Tenant", out var tenantHeader) ? tenantHeader.ToString() : "default"); // Get user from header for filtering var userId = httpContext.Request.Headers.TryGetValue("X-StellaOps-User", out var userHeader) ? userHeader.ToString() : null; var conversations = await conversationService.ListAsync(effectiveTenantId, userId, limit, cancellationToken) .ConfigureAwait(false); var summaries = conversations.Select(c => new StellaOps.AdvisoryAI.WebService.Contracts.ConversationSummary { ConversationId = c.ConversationId, CreatedAt = c.CreatedAt, UpdatedAt = c.UpdatedAt, TurnCount = c.Turns.Length, Preview = c.Turns.FirstOrDefault(t => t.Role == TurnRole.User)?.Content is { } content ? content.Length > 100 ? content[..100] + "..." : content : null }).ToList(); return Results.Ok(new StellaOps.AdvisoryAI.WebService.Contracts.ConversationListResponse { Conversations = summaries, TotalCount = summaries.Count }); } static bool EnsureChatAuthorized(HttpContext context) { var tokens = new HashSet(StringComparer.OrdinalIgnoreCase); if (context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes)) { AddHeaderTokens(tokens, scopes); } if (context.Request.Headers.TryGetValue("X-StellaOps-Roles", out var roles)) { AddHeaderTokens(tokens, roles); } return tokens.Contains("advisory:run") || tokens.Contains("advisory:chat") || tokens.Contains("chat:user") || tokens.Contains("chat:admin"); } static void AddHeaderTokens(HashSet target, IEnumerable values) { foreach (var value in values) { if (string.IsNullOrWhiteSpace(value)) { continue; } foreach (var token in value.Split( new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { target.Add(token); } } } static string GeneratePlaceholderResponse(string userMessage) { // Placeholder implementation - in production this would call the LLM return $"I received your message: \"{userMessage}\". This is a placeholder response. " + "The full chat functionality with grounded responses will be implemented when the LLM pipeline is connected."; } static async IAsyncEnumerable StreamPlaceholderTokens( string content, [EnumeratorCancellation] CancellationToken cancellationToken) { foreach (var token in content.Split( ' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { cancellationToken.ThrowIfCancellationRequested(); yield return new TokenChunk { Content = token + " " }; await Task.Yield(); } } internal sealed record PipelinePlanRequest( AdvisoryTaskType? TaskType, string AdvisoryKey, string? ArtifactId, string? ArtifactPurl, string? PolicyVersion, string Profile = "default", IReadOnlyCollection? 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 Requests { get; init; } = Array.Empty(); } // Make Program class accessible for WebApplicationFactory in tests namespace StellaOps.AdvisoryAI.WebService { public partial class Program { } }