audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -14,4 +14,12 @@ public sealed class AdvisoryAiGuardrailOptions
= null;
public List<string> BlockedPhrases { get; set; } = new();
public double? EntropyThreshold { get; set; } = 3.5;
public int? EntropyMinLength { get; set; } = 20;
public string? AllowlistFile { get; set; } = null;
public List<string> AllowlistPatterns { get; set; } = new();
}

View File

@@ -81,6 +81,18 @@ internal static class AdvisoryAiServiceOptionsValidator
return false;
}
if (options.Guardrails.EntropyThreshold.HasValue && options.Guardrails.EntropyThreshold.Value < 0)
{
error = "AdvisoryAI:Guardrails:EntropyThreshold must be >= 0 when specified.";
return false;
}
if (options.Guardrails.EntropyMinLength.HasValue && options.Guardrails.EntropyMinLength.Value < 0)
{
error = "AdvisoryAI:Guardrails:EntropyMinLength must be >= 0 when specified.";
return false;
}
error = null;
return true;
}

View File

@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Hosting;
internal static class GuardrailAllowlistLoader
{
public static IReadOnlyCollection<string> Load(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Guardrail allowlist file path must be provided.", nameof(path));
}
if (!File.Exists(path))
{
throw new FileNotFoundException($"Guardrail allowlist file {path} was not found.", path);
}
var contents = File.ReadAllText(path);
if (LooksLikeJson(contents))
{
return LoadJson(contents, path);
}
return LoadLines(contents);
}
private static bool LooksLikeJson(string contents)
{
foreach (var ch in contents)
{
if (char.IsWhiteSpace(ch))
{
continue;
}
return ch == '{' || ch == '[';
}
return false;
}
private static IReadOnlyCollection<string> LoadLines(string contents)
{
var patterns = new List<string>();
using var reader = new StringReader(contents);
while (reader.ReadLine() is { } line)
{
var trimmed = line.Trim();
if (trimmed.Length == 0 || trimmed.StartsWith("#", StringComparison.Ordinal))
{
continue;
}
patterns.Add(trimmed);
}
return patterns;
}
private static IReadOnlyCollection<string> LoadJson(string contents, string path)
{
using var document = JsonDocument.Parse(contents);
var root = document.RootElement;
return root.ValueKind switch
{
JsonValueKind.Array => ExtractValues(root),
JsonValueKind.Object when root.TryGetProperty("allowlist", out var allowlist) => ExtractValues(allowlist),
JsonValueKind.Object when root.TryGetProperty("patterns", out var patterns) => ExtractValues(patterns),
_ => throw new InvalidDataException(
$"Guardrail allowlist file {path} must be a JSON array or object with an allowlist/patterns array.")
};
}
private static IReadOnlyCollection<string> ExtractValues(JsonElement element)
{
var patterns = new List<string>();
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.String)
{
continue;
}
var value = item.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
patterns.Add(value.Trim());
}
}
return patterns;
}
}

View File

@@ -11,12 +11,16 @@ using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Caching;
using StellaOps.AdvisoryAI.Chat;
using StellaOps.AdvisoryAI.DependencyInjection;
using StellaOps.AdvisoryAI.Explanation;
using StellaOps.AdvisoryAI.Inference;
using StellaOps.AdvisoryAI.Metrics;
using StellaOps.AdvisoryAI.Guardrails;
using StellaOps.AdvisoryAI.Outputs;
using StellaOps.AdvisoryAI.PolicyStudio;
using StellaOps.AdvisoryAI.Providers;
using StellaOps.AdvisoryAI.Queue;
using StellaOps.AdvisoryAI.Remediation;
using StellaOps.OpsMemory.Storage;
namespace StellaOps.AdvisoryAI.Hosting;
@@ -100,6 +104,8 @@ public static class ServiceCollectionExtensions
// Register deterministic providers (allow test injection)
services.TryAddSingleton<IGuidProvider>(SystemGuidProvider.Instance);
services.TryAddSingleton<StellaOps.Determinism.IGuidProvider>(
StellaOps.Determinism.SystemGuidProvider.Instance);
services.TryAddSingleton(TimeProvider.System);
services.Replace(ServiceDescriptor.Singleton<IAdvisoryTaskQueue, FileSystemAdvisoryTaskQueue>());
@@ -107,17 +113,38 @@ public static class ServiceCollectionExtensions
services.Replace(ServiceDescriptor.Singleton<IAdvisoryOutputStore, FileSystemAdvisoryOutputStore>());
services.TryAddSingleton<AdvisoryAiMetrics>();
// Explanation services (SPRINT_20251226_015_AI_zastava_companion)
services.TryAddSingleton<IEvidenceRetrievalService, NullEvidenceRetrievalService>();
services.TryAddSingleton<IExplanationPromptService, DefaultExplanationPromptService>();
services.TryAddSingleton<IExplanationInferenceClient, NullExplanationInferenceClient>();
services.TryAddSingleton<ICitationExtractor, NullCitationExtractor>();
services.TryAddSingleton<IExplanationStore, InMemoryExplanationStore>();
services.TryAddSingleton<IExplanationGenerator, EvidenceAnchoredExplanationGenerator>();
// Remediation services (SPRINT_20251226_016_AI_remedy_autopilot)
services.TryAddSingleton<IRemediationPlanner, NullRemediationPlanner>();
// Policy studio services (SPRINT_20251226_017_AI_policy_copilot)
services.TryAddSingleton<IPolicyIntentStore, InMemoryPolicyIntentStore>();
services.TryAddSingleton<IPolicyIntentParser, NullPolicyIntentParser>();
services.TryAddSingleton<IPolicyRuleGenerator, LatticeRuleGenerator>();
services.TryAddSingleton<ITestCaseSynthesizer, PropertyBasedTestSynthesizer>();
// Chat services (SPRINT_20260107_006_003 CH-005)
services.AddOptions<ConversationOptions>()
.Bind(configuration.GetSection("AdvisoryAI:Chat"))
.ValidateOnStart();
services.TryAddSingleton<IGuidGenerator, DefaultGuidGenerator>();
services.TryAddSingleton<IConversationService, ConversationService>();
services.TryAddSingleton<ConversationContextBuilder>();
services.TryAddSingleton<ChatPromptAssembler>();
services.TryAddSingleton<ChatResponseStreamer>();
services.TryAddSingleton<GroundingValidator>();
services.TryAddSingleton<ActionProposalParser>();
// Action policy gate and audit defaults (SPRINT_20260109_011_004_BE)
services.AddDefaultActionPolicyIntegration();
// Object link resolvers (SPRINT_20260109_011_002 OMCI-005)
services.TryAddSingleton<ITypedLinkResolver, OpsMemoryLinkResolver>();
services.TryAddSingleton<IObjectLinkResolver, CompositeObjectLinkResolver>();
@@ -142,6 +169,16 @@ public static class ServiceCollectionExtensions
target.RequireCitations = source.RequireCitations;
if (source.EntropyThreshold.HasValue && source.EntropyThreshold.Value >= 0)
{
target.EntropyThreshold = source.EntropyThreshold.Value;
}
if (source.EntropyMinLength.HasValue && source.EntropyMinLength.Value >= 0)
{
target.EntropyMinLength = source.EntropyMinLength.Value;
}
var defaults = target.BlockedPhrases.ToList();
var merged = new SortedSet<string>(defaults, StringComparer.OrdinalIgnoreCase);
@@ -168,15 +205,48 @@ public static class ServiceCollectionExtensions
}
}
if (merged.Count == 0)
if (merged.Count > 0)
{
return;
target.BlockedPhrases.Clear();
foreach (var phrase in merged)
{
target.BlockedPhrases.Add(phrase);
}
}
target.BlockedPhrases.Clear();
foreach (var phrase in merged)
var allowlistDefaults = target.AllowlistPatterns.ToList();
var allowlist = new SortedSet<string>(allowlistDefaults, StringComparer.OrdinalIgnoreCase);
if (source.AllowlistPatterns is { Count: > 0 })
{
target.BlockedPhrases.Add(phrase);
foreach (var pattern in source.AllowlistPatterns)
{
if (!string.IsNullOrWhiteSpace(pattern))
{
allowlist.Add(pattern.Trim());
}
}
}
if (!string.IsNullOrWhiteSpace(source.AllowlistFile))
{
var resolvedPath = ResolveGuardrailPath(source.AllowlistFile!, environment);
foreach (var pattern in GuardrailAllowlistLoader.Load(resolvedPath))
{
if (!string.IsNullOrWhiteSpace(pattern))
{
allowlist.Add(pattern.Trim());
}
}
}
if (allowlist.Count > 0)
{
target.AllowlistPatterns.Clear();
foreach (var pattern in allowlist)
{
target.AllowlistPatterns.Add(pattern);
}
}
}

View File

@@ -17,6 +17,7 @@ using StellaOps.AdvisoryAI.Chat.Models;
using StellaOps.AdvisoryAI.Chat.Options;
using StellaOps.AdvisoryAI.Chat.Routing;
using StellaOps.AdvisoryAI.Chat.Services;
using StellaOps.AdvisoryAI.Chat.Settings;
using StellaOps.AdvisoryAI.WebService.Contracts;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
@@ -76,6 +77,29 @@ public static class ChatEndpoints
.Produces<EvidenceBundlePreviewResponse>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest);
// Settings endpoints
group.MapGet("/settings", GetChatSettingsAsync)
.WithName("GetChatSettings")
.WithSummary("Gets effective chat settings for the caller")
.Produces<ChatSettingsResponse>(StatusCodes.Status200OK);
group.MapPut("/settings", UpdateChatSettingsAsync)
.WithName("UpdateChatSettings")
.WithSummary("Updates chat settings overrides (tenant or user)")
.Produces<ChatSettingsResponse>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest);
group.MapDelete("/settings", ClearChatSettingsAsync)
.WithName("ClearChatSettings")
.WithSummary("Clears chat settings overrides (tenant or user)")
.Produces(StatusCodes.Status204NoContent);
// Doctor endpoint
group.MapGet("/doctor", GetChatDoctorAsync)
.WithName("GetChatDoctor")
.WithSummary("Returns chat limit status and tool access diagnostics")
.Produces<ChatDoctorResponse>(StatusCodes.Status200OK);
// Health/status endpoint for chat service
group.MapGet("/status", GetChatStatusAsync)
.WithName("GetChatStatus")
@@ -131,16 +155,48 @@ public static class ChatEndpoints
{
var statusCode = result.GuardrailBlocked
? StatusCodes.Status400BadRequest
: StatusCodes.Status500InternalServerError;
: result.QuotaBlocked
? StatusCodes.Status429TooManyRequests
: result.ToolAccessDenied
? StatusCodes.Status403Forbidden
: StatusCodes.Status500InternalServerError;
var code = result.GuardrailBlocked
? "GUARDRAIL_BLOCKED"
: result.QuotaBlocked
? result.QuotaCode ?? "QUOTA_EXCEEDED"
: result.ToolAccessDenied
? "TOOL_DENIED"
: "PROCESSING_FAILED";
Dictionary<string, object>? details = null;
if (result.GuardrailBlocked)
{
details = result.GuardrailViolations.ToDictionary(v => v, _ => (object)"violation");
}
else if (result.QuotaBlocked && result.QuotaStatus is not null)
{
details = new Dictionary<string, object> { ["quota"] = result.QuotaStatus };
}
else if (result.ToolAccessDenied)
{
details = new Dictionary<string, object>
{
["reason"] = result.ToolAccessReason ?? "Tool access denied"
};
}
var doctor = result.QuotaBlocked || result.ToolAccessDenied
? CreateDoctorAction(code)
: null;
return Results.Json(
new ErrorResponse
{
Error = result.Error ?? "Query processing failed",
Code = result.GuardrailBlocked ? "GUARDRAIL_BLOCKED" : "PROCESSING_FAILED",
Details = result.GuardrailBlocked
? result.GuardrailViolations.ToDictionary(v => v, _ => (object)"violation")
: null
Code = code,
Details = details,
Doctor = doctor
},
statusCode: statusCode);
}
@@ -154,6 +210,8 @@ public static class ChatEndpoints
[FromServices] IEvidenceBundleAssembler evidenceAssembler,
[FromServices] IAdvisoryChatInferenceClient inferenceClient,
[FromServices] IOptions<AdvisoryChatOptions> options,
[FromServices] IAdvisoryChatSettingsService settingsService,
[FromServices] IAdvisoryChatQuotaService quotaService,
[FromServices] ILogger<AdvisoryChatQueryRequest> logger,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
@@ -180,6 +238,7 @@ public static class ChatEndpoints
}
tenantId ??= "default";
userId ??= "anonymous";
httpContext.Response.ContentType = "text/event-stream";
httpContext.Response.Headers.CacheControl = "no-cache";
@@ -214,6 +273,55 @@ public static class ChatEndpoints
return;
}
var settings = await settingsService.GetEffectiveSettingsAsync(
tenantId,
userId,
ct);
var toolPolicy = AdvisoryChatToolPolicy.Resolve(
settings.Tools,
options.Value.DataProviders,
includeReachability: true,
includeBinaryPatch: true,
includeOpsMemory: true);
if (!toolPolicy.AllowSbom)
{
await WriteStreamEventAsync(httpContext, "error", new
{
code = "TOOL_DENIED",
message = "Tool access denied: sbom.read",
doctor = CreateDoctorAction("TOOL_DENIED")
}, ct);
await WriteStreamEventAsync(httpContext, "done", new { success = false }, ct);
return;
}
var quotaDecision = await quotaService.TryConsumeAsync(
new ChatQuotaRequest
{
TenantId = tenantId,
UserId = userId,
EstimatedTokens = options.Value.Inference.MaxTokens,
ToolCalls = toolPolicy.ToolCallCount
},
settings.Quotas,
ct);
if (!quotaDecision.Allowed)
{
var quotaCode = quotaDecision.Code ?? "QUOTA_EXCEEDED";
await WriteStreamEventAsync(httpContext, "error", new
{
code = quotaCode,
message = quotaDecision.Message ?? "Quota exceeded",
quota = quotaDecision.Status,
doctor = CreateDoctorAction(quotaCode)
}, ct);
await WriteStreamEventAsync(httpContext, "done", new { success = false }, ct);
return;
}
// Step 3: Assemble evidence bundle
await WriteStreamEventAsync(httpContext, "status", new { phase = "assembling_evidence" }, ct);
@@ -226,6 +334,15 @@ public static class ChatEndpoints
Environment = request.Environment ?? "unknown",
FindingId = findingId,
PackagePurl = routingResult.Parameters.Package,
IncludeSbom = toolPolicy.AllowSbom,
IncludeVex = toolPolicy.AllowVex,
IncludePolicy = toolPolicy.AllowPolicy,
IncludeProvenance = toolPolicy.AllowProvenance,
IncludeFix = toolPolicy.AllowFix,
IncludeContext = toolPolicy.AllowContext,
IncludeReachability = toolPolicy.AllowReachability,
IncludeBinaryPatch = toolPolicy.AllowBinaryPatch,
IncludeOpsMemory = toolPolicy.AllowOpsMemory,
CorrelationId = correlationId
},
ct);
@@ -324,7 +441,11 @@ public static class ChatEndpoints
private static async Task<IResult> PreviewEvidenceBundleAsync(
[FromBody] EvidencePreviewRequest request,
[FromServices] IEvidenceBundleAssembler evidenceAssembler,
[FromServices] IOptions<AdvisoryChatOptions> options,
[FromServices] IAdvisoryChatSettingsService settingsService,
[FromServices] IAdvisoryChatQuotaService quotaService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
[FromHeader(Name = "X-Correlation-Id")] string? correlationId,
CancellationToken ct)
{
@@ -334,6 +455,52 @@ public static class ChatEndpoints
}
tenantId ??= "default";
userId ??= "anonymous";
var settings = await settingsService.GetEffectiveSettingsAsync(tenantId, userId, ct);
var toolPolicy = AdvisoryChatToolPolicy.Resolve(
settings.Tools,
options.Value.DataProviders,
includeReachability: true,
includeBinaryPatch: true,
includeOpsMemory: true);
if (!toolPolicy.AllowSbom)
{
return Results.Json(
new ErrorResponse
{
Error = "Tool access denied: sbom.read",
Code = "TOOL_DENIED",
Doctor = CreateDoctorAction("TOOL_DENIED")
},
statusCode: StatusCodes.Status403Forbidden);
}
var quotaDecision = await quotaService.TryConsumeAsync(
new ChatQuotaRequest
{
TenantId = tenantId,
UserId = userId,
EstimatedTokens = 0,
ToolCalls = toolPolicy.ToolCallCount
},
settings.Quotas,
ct);
if (!quotaDecision.Allowed)
{
var quotaCode = quotaDecision.Code ?? "QUOTA_EXCEEDED";
return Results.Json(
new ErrorResponse
{
Error = quotaDecision.Message ?? "Quota exceeded",
Code = quotaCode,
Details = new Dictionary<string, object> { ["quota"] = quotaDecision.Status },
Doctor = CreateDoctorAction(quotaCode)
},
statusCode: StatusCodes.Status429TooManyRequests);
}
var assemblyResult = await evidenceAssembler.AssembleAsync(
new EvidenceBundleAssemblyRequest
@@ -344,6 +511,15 @@ public static class ChatEndpoints
Environment = request.Environment ?? "unknown",
FindingId = request.FindingId,
PackagePurl = request.PackagePurl,
IncludeSbom = toolPolicy.AllowSbom,
IncludeVex = toolPolicy.AllowVex,
IncludePolicy = toolPolicy.AllowPolicy,
IncludeProvenance = toolPolicy.AllowProvenance,
IncludeFix = toolPolicy.AllowFix,
IncludeContext = toolPolicy.AllowContext,
IncludeReachability = toolPolicy.AllowReachability,
IncludeBinaryPatch = toolPolicy.AllowBinaryPatch,
IncludeOpsMemory = toolPolicy.AllowOpsMemory,
CorrelationId = correlationId
},
ct);
@@ -379,6 +555,126 @@ public static class ChatEndpoints
});
}
private static async Task<IResult> GetChatSettingsAsync(
[FromServices] IAdvisoryChatSettingsService settingsService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
CancellationToken ct)
{
tenantId ??= "default";
userId ??= "anonymous";
var settings = await settingsService.GetEffectiveSettingsAsync(tenantId, userId, ct);
return Results.Ok(ChatSettingsResponse.FromSettings(settings));
}
private static async Task<IResult> UpdateChatSettingsAsync(
[FromBody] ChatSettingsUpdateRequest request,
[FromServices] IAdvisoryChatSettingsService settingsService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
[FromQuery(Name = "scope")] string? scope,
CancellationToken ct)
{
tenantId ??= "default";
userId ??= "anonymous";
if (request is null)
{
return Results.BadRequest(new ErrorResponse { Error = "Settings payload is required", Code = "INVALID_SETTINGS" });
}
var overrides = new AdvisoryChatSettingsOverrides
{
Quotas = new ChatQuotaOverrides
{
RequestsPerMinute = request.Quotas?.RequestsPerMinute,
RequestsPerDay = request.Quotas?.RequestsPerDay,
TokensPerDay = request.Quotas?.TokensPerDay,
ToolCallsPerDay = request.Quotas?.ToolCallsPerDay
},
Tools = new ChatToolAccessOverrides
{
AllowAll = request.Tools?.AllowAll,
AllowedTools = request.Tools?.AllowedTools is null
? null
: request.Tools.AllowedTools.ToImmutableArray()
}
};
if (string.Equals(scope, "user", StringComparison.OrdinalIgnoreCase))
{
await settingsService.SetUserOverridesAsync(tenantId, userId, overrides, ct);
}
else
{
await settingsService.SetTenantOverridesAsync(tenantId, overrides, ct);
}
var effective = await settingsService.GetEffectiveSettingsAsync(tenantId, userId, ct);
return Results.Ok(ChatSettingsResponse.FromSettings(effective));
}
private static async Task<IResult> ClearChatSettingsAsync(
[FromServices] IAdvisoryChatSettingsService settingsService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
[FromQuery(Name = "scope")] string? scope,
CancellationToken ct)
{
tenantId ??= "default";
userId ??= "anonymous";
if (string.Equals(scope, "user", StringComparison.OrdinalIgnoreCase))
{
await settingsService.ClearUserOverridesAsync(tenantId, userId, ct);
}
else
{
await settingsService.ClearTenantOverridesAsync(tenantId, ct);
}
return Results.NoContent();
}
private static async Task<IResult> GetChatDoctorAsync(
[FromServices] IAdvisoryChatSettingsService settingsService,
[FromServices] IAdvisoryChatQuotaService quotaService,
[FromServices] IOptions<AdvisoryChatOptions> options,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
CancellationToken ct)
{
tenantId ??= "default";
userId ??= "anonymous";
var settings = await settingsService.GetEffectiveSettingsAsync(tenantId, userId, ct);
var toolPolicy = AdvisoryChatToolPolicy.Resolve(
settings.Tools,
options.Value.DataProviders,
includeReachability: true,
includeBinaryPatch: true,
includeOpsMemory: true);
var quotaStatus = quotaService.GetStatus(tenantId, userId, settings.Quotas);
return Results.Ok(new ChatDoctorResponse
{
TenantId = tenantId,
UserId = userId,
Quotas = ChatQuotaStatusResponse.FromStatus(quotaStatus),
Tools = ChatToolAccessResponse.FromPolicy(settings.Tools, toolPolicy),
LastDenied = quotaStatus.LastDenied is null
? null
: new ChatDenialResponse
{
Code = quotaStatus.LastDenied.Code,
Message = quotaStatus.LastDenied.Message,
DeniedAt = quotaStatus.LastDenied.DeniedAt
}
});
}
private static Task<IResult> GetChatStatusAsync(
[FromServices] IOptions<AdvisoryChatOptions> options)
{
@@ -395,6 +691,16 @@ public static class ChatEndpoints
}));
}
private static ChatDoctorAction CreateDoctorAction(string? reason)
{
return new ChatDoctorAction
{
Endpoint = "/api/v1/chat/doctor",
SuggestedCommand = "stella advise doctor",
Reason = reason
};
}
private static async Task WriteStreamEventAsync<T>(
HttpContext context,
string eventType,
@@ -741,12 +1047,178 @@ public sealed record ChatServiceStatusResponse
public required bool AuditEnabled { get; init; }
}
/// <summary>Chat settings update request.</summary>
public sealed record ChatSettingsUpdateRequest
{
public ChatQuotaSettingsUpdateRequest? Quotas { get; init; }
public ChatToolAccessUpdateRequest? Tools { get; init; }
}
/// <summary>Quota update request.</summary>
public sealed record ChatQuotaSettingsUpdateRequest
{
public int? RequestsPerMinute { get; init; }
public int? RequestsPerDay { get; init; }
public int? TokensPerDay { get; init; }
public int? ToolCallsPerDay { get; init; }
}
/// <summary>Tool access update request.</summary>
public sealed record ChatToolAccessUpdateRequest
{
public bool? AllowAll { get; init; }
public List<string>? AllowedTools { get; init; }
}
/// <summary>Chat settings response.</summary>
public sealed record ChatSettingsResponse
{
public required ChatQuotaSettingsResponse Quotas { get; init; }
public required ChatToolAccessResponse Tools { get; init; }
public static ChatSettingsResponse FromSettings(AdvisoryChatSettings settings)
{
return new ChatSettingsResponse
{
Quotas = new ChatQuotaSettingsResponse
{
RequestsPerMinute = settings.Quotas.RequestsPerMinute,
RequestsPerDay = settings.Quotas.RequestsPerDay,
TokensPerDay = settings.Quotas.TokensPerDay,
ToolCallsPerDay = settings.Quotas.ToolCallsPerDay
},
Tools = new ChatToolAccessResponse
{
AllowAll = settings.Tools.AllowAll,
AllowedTools = settings.Tools.AllowedTools.ToList()
}
};
}
}
/// <summary>Quota settings response.</summary>
public sealed record ChatQuotaSettingsResponse
{
public required int RequestsPerMinute { get; init; }
public required int RequestsPerDay { get; init; }
public required int TokensPerDay { get; init; }
public required int ToolCallsPerDay { get; init; }
}
/// <summary>Tool access response.</summary>
public sealed record ChatToolAccessResponse
{
public required bool AllowAll { get; init; }
public List<string> AllowedTools { get; init; } = [];
public ChatToolProviderResponse? Providers { get; init; }
public static ChatToolAccessResponse FromPolicy(
ChatToolAccessSettings settings,
ChatToolPolicyResult policy)
{
return new ChatToolAccessResponse
{
AllowAll = settings.AllowAll,
AllowedTools = policy.AllowedTools.ToList(),
Providers = new ChatToolProviderResponse
{
Sbom = policy.AllowSbom,
Vex = policy.AllowVex,
Reachability = policy.AllowReachability,
BinaryPatch = policy.AllowBinaryPatch,
OpsMemory = policy.AllowOpsMemory,
Policy = policy.AllowPolicy,
Provenance = policy.AllowProvenance,
Fix = policy.AllowFix,
Context = policy.AllowContext
}
};
}
}
/// <summary>Tool provider availability response.</summary>
public sealed record ChatToolProviderResponse
{
public bool Sbom { get; init; }
public bool Vex { get; init; }
public bool Reachability { get; init; }
public bool BinaryPatch { get; init; }
public bool OpsMemory { get; init; }
public bool Policy { get; init; }
public bool Provenance { get; init; }
public bool Fix { get; init; }
public bool Context { get; init; }
}
/// <summary>Chat doctor response.</summary>
public sealed record ChatDoctorResponse
{
public required string TenantId { get; init; }
public required string UserId { get; init; }
public required ChatQuotaStatusResponse Quotas { get; init; }
public required ChatToolAccessResponse Tools { get; init; }
public ChatDenialResponse? LastDenied { get; init; }
}
/// <summary>Doctor action hint.</summary>
public sealed record ChatDoctorAction
{
public required string Endpoint { get; init; }
public required string SuggestedCommand { get; init; }
public string? Reason { get; init; }
}
/// <summary>Quota status response.</summary>
public sealed record ChatQuotaStatusResponse
{
public required int RequestsPerMinuteLimit { get; init; }
public required int RequestsPerMinuteRemaining { get; init; }
public required DateTimeOffset RequestsPerMinuteResetsAt { get; init; }
public required int RequestsPerDayLimit { get; init; }
public required int RequestsPerDayRemaining { get; init; }
public required DateTimeOffset RequestsPerDayResetsAt { get; init; }
public required int TokensPerDayLimit { get; init; }
public required int TokensPerDayRemaining { get; init; }
public required DateTimeOffset TokensPerDayResetsAt { get; init; }
public required int ToolCallsPerDayLimit { get; init; }
public required int ToolCallsPerDayRemaining { get; init; }
public required DateTimeOffset ToolCallsPerDayResetsAt { get; init; }
public static ChatQuotaStatusResponse FromStatus(ChatQuotaStatus status)
{
return new ChatQuotaStatusResponse
{
RequestsPerMinuteLimit = status.RequestsPerMinuteLimit,
RequestsPerMinuteRemaining = status.RequestsPerMinuteRemaining,
RequestsPerMinuteResetsAt = status.RequestsPerMinuteResetsAt,
RequestsPerDayLimit = status.RequestsPerDayLimit,
RequestsPerDayRemaining = status.RequestsPerDayRemaining,
RequestsPerDayResetsAt = status.RequestsPerDayResetsAt,
TokensPerDayLimit = status.TokensPerDayLimit,
TokensPerDayRemaining = status.TokensPerDayRemaining,
TokensPerDayResetsAt = status.TokensPerDayResetsAt,
ToolCallsPerDayLimit = status.ToolCallsPerDayLimit,
ToolCallsPerDayRemaining = status.ToolCallsPerDayRemaining,
ToolCallsPerDayResetsAt = status.ToolCallsPerDayResetsAt
};
}
}
/// <summary>Quota denial response.</summary>
public sealed record ChatDenialResponse
{
public required string Code { get; init; }
public required string Message { get; init; }
public required DateTimeOffset DeniedAt { get; init; }
}
/// <summary>Error response.</summary>
public sealed record ErrorResponse
{
public required string Error { get; init; }
public string? Code { get; init; }
public Dictionary<string, object>? Details { get; init; }
public ChatDoctorAction? Doctor { get; init; }
}
#endregion

View File

@@ -2,12 +2,14 @@ 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;
@@ -15,6 +17,7 @@ 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;
@@ -36,6 +39,7 @@ builder.Configuration
.AddEnvironmentVariables(prefix: "ADVISORYAI__");
builder.Services.AddAdvisoryAiCore(builder.Configuration);
builder.Services.AddAdvisoryChat(builder.Configuration);
// Authorization service
builder.Services.AddSingleton<StellaOps.AdvisoryAI.WebService.Services.IAuthorizationService, StellaOps.AdvisoryAI.WebService.Services.HeaderBasedAuthorizationService>();
@@ -59,6 +63,7 @@ builder.Services.AddInMemoryAiAttestationStore();
// Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-010)
builder.Services.AddEvidencePack();
builder.Services.TryAddSingleton<IEvidencePackSigner, NullEvidencePackSigner>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
@@ -189,6 +194,9 @@ app.MapDelete("/v1/advisory-ai/conversations/{conversationId}", HandleDeleteConv
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();
@@ -1096,10 +1104,40 @@ static async Task<IResult> HandleAddTurn(
? null
: assistantTurn.ProposedActions.Select(StellaOps.AdvisoryAI.WebService.Contracts.ProposedActionResponse.FromAction).ToList(),
GroundingScore = 1.0, // Placeholder
TokenCount = assistantContent.Split(' ').Length, // Rough estimate
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)
@@ -1180,25 +1218,63 @@ static async Task<IResult> HandleListConversations(
static bool EnsureChatAuthorized(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
var tokens = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
{
return false;
AddHeaderTokens(tokens, scopes);
}
var allowed = scopes
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (context.Request.Headers.TryGetValue("X-StellaOps-Roles", out var roles))
{
AddHeaderTokens(tokens, roles);
}
return allowed.Contains("advisory:run") || allowed.Contains("advisory:chat");
return tokens.Contains("advisory:run")
|| tokens.Contains("advisory:chat")
|| tokens.Contains("chat:user")
|| tokens.Contains("chat:admin");
}
static void AddHeaderTokens(HashSet<string> target, IEnumerable<string> 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
// 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<TokenChunk> 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,
@@ -1232,3 +1308,9 @@ internal sealed record BatchPipelinePlanRequest
{
public IReadOnlyList<PipelinePlanRequest> Requests { get; init; } = Array.Empty<PipelinePlanRequest>();
}
// Make Program class accessible for WebApplicationFactory in tests
namespace StellaOps.AdvisoryAI.WebService
{
public partial class Program { }
}

View File

@@ -13,6 +13,7 @@ builder.Configuration
.AddEnvironmentVariables(prefix: "ADVISORYAI__");
builder.Services.AddAdvisoryAiCore(builder.Configuration);
builder.Services.AddSingleton<IAdvisoryJitterSource, DefaultAdvisoryJitterSource>();
builder.Services.AddHostedService<AdvisoryTaskWorker>();
var host = builder.Build();

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.AdvisoryAI.Tests")]

View File

@@ -10,7 +10,7 @@ using StellaOps.AdvisoryAI.Execution;
namespace StellaOps.AdvisoryAI.Worker.Services;
internal sealed class AdvisoryTaskWorker : BackgroundService
public class AdvisoryTaskWorker : BackgroundService
{
private const int MaxRetryDelaySeconds = 60;
private const int BaseRetryDelaySeconds = 2;
@@ -22,7 +22,7 @@ internal sealed class AdvisoryTaskWorker : BackgroundService
private readonly AdvisoryPipelineMetrics _metrics;
private readonly IAdvisoryPipelineExecutor _executor;
private readonly TimeProvider _timeProvider;
private readonly Func<double> _jitterSource;
private readonly IAdvisoryJitterSource _jitterSource;
private readonly ILogger<AdvisoryTaskWorker> _logger;
private int _consecutiveErrors;
@@ -34,7 +34,7 @@ internal sealed class AdvisoryTaskWorker : BackgroundService
IAdvisoryPipelineExecutor executor,
TimeProvider timeProvider,
ILogger<AdvisoryTaskWorker> logger,
Func<double>? jitterSource = null)
IAdvisoryJitterSource jitterSource)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
@@ -42,7 +42,7 @@ internal sealed class AdvisoryTaskWorker : BackgroundService
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_jitterSource = jitterSource ?? Random.Shared.NextDouble;
_jitterSource = jitterSource ?? throw new ArgumentNullException(nameof(jitterSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -150,7 +150,7 @@ internal sealed class AdvisoryTaskWorker : BackgroundService
var backoff = Math.Min(BaseRetryDelaySeconds * Math.Pow(2, errorCount - 1), MaxRetryDelaySeconds);
// Add jitter (+/- JitterFactor percent) using injectable source for testability
var jitter = backoff * JitterFactor * (2 * _jitterSource() - 1);
var jitter = backoff * JitterFactor * (2 * _jitterSource.NextDouble() - 1);
return Math.Max(BaseRetryDelaySeconds, backoff + jitter);
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.AdvisoryAI.Worker.Services;
public interface IAdvisoryJitterSource
{
double NextDouble();
}
internal sealed class DefaultAdvisoryJitterSource : IAdvisoryJitterSource
{
private readonly Random _random;
private readonly object _lock = new();
public DefaultAdvisoryJitterSource(TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(timeProvider);
var seed = unchecked((int)timeProvider.GetUtcNow().ToUnixTimeMilliseconds());
_random = new Random(seed);
}
public double NextDouble()
{
lock (_lock)
{
return _random.NextDouble();
}
}
}

View File

@@ -79,6 +79,11 @@ internal sealed class EvidenceBundleAssembler : IEvidenceBundleAssembler
var assembledAt = _timeProvider.GetUtcNow();
// Phase 1: Core data (sequential - needed for subsequent lookups)
if (!request.IncludeSbom)
{
return CreateFailure("SBOM access disabled by tool policy.");
}
var sbomData = await _sbomProvider.GetSbomDataAsync(
request.TenantId, request.ArtifactDigest, cancellationToken);
@@ -96,36 +101,78 @@ internal sealed class EvidenceBundleAssembler : IEvidenceBundleAssembler
}
// Phase 2: Parallel data retrieval
var vexTask = _vexProvider.GetVexDataAsync(
request.TenantId, request.FindingId, findingData.Package, cancellationToken);
Task<VexData?> vexTask = request.IncludeVex
? _vexProvider.GetVexDataAsync(
request.TenantId, request.FindingId, findingData.Package, cancellationToken)
: Task.FromResult<VexData?>(null);
if (!request.IncludeVex)
{
warnings.Add("VEX data disabled by tool policy.");
}
var policyTask = _policyProvider.GetPolicyEvaluationsAsync(
request.TenantId, request.ArtifactDigest, request.FindingId, request.Environment, cancellationToken);
Task<PolicyData?> policyTask = request.IncludePolicy
? _policyProvider.GetPolicyEvaluationsAsync(
request.TenantId, request.ArtifactDigest, request.FindingId, request.Environment, cancellationToken)
: Task.FromResult<PolicyData?>(null);
if (!request.IncludePolicy)
{
warnings.Add("Policy data disabled by tool policy.");
}
var provenanceTask = _provenanceProvider.GetProvenanceDataAsync(
request.TenantId, request.ArtifactDigest, cancellationToken);
Task<ProvenanceData?> provenanceTask = request.IncludeProvenance
? _provenanceProvider.GetProvenanceDataAsync(
request.TenantId, request.ArtifactDigest, cancellationToken)
: Task.FromResult<ProvenanceData?>(null);
if (!request.IncludeProvenance)
{
warnings.Add("Provenance data disabled by tool policy.");
}
var fixTask = _fixProvider.GetFixDataAsync(
request.TenantId, request.FindingId, findingData.Package, findingData.Version, cancellationToken);
Task<FixData?> fixTask = request.IncludeFix
? _fixProvider.GetFixDataAsync(
request.TenantId, request.FindingId, findingData.Package, findingData.Version, cancellationToken)
: Task.FromResult<FixData?>(null);
if (!request.IncludeFix)
{
warnings.Add("Fix data disabled by tool policy.");
}
var contextTask = _contextProvider.GetContextDataAsync(
request.TenantId, request.Environment, cancellationToken);
Task<ContextData?> contextTask = request.IncludeContext
? _contextProvider.GetContextDataAsync(
request.TenantId, request.Environment, cancellationToken)
: Task.FromResult<ContextData?>(null);
if (!request.IncludeContext)
{
warnings.Add("Context data disabled by tool policy.");
}
// Conditional parallel tasks
Task<ReachabilityData?> reachabilityTask = request.IncludeReachability
? _reachabilityProvider.GetReachabilityDataAsync(
request.TenantId, request.ArtifactDigest, findingData.Package, request.FindingId, cancellationToken)
: Task.FromResult<ReachabilityData?>(null);
if (!request.IncludeReachability)
{
warnings.Add("Reachability data disabled by tool policy.");
}
Task<BinaryPatchData?> binaryPatchTask = request.IncludeBinaryPatch
? _binaryPatchProvider.GetBinaryPatchDataAsync(
request.TenantId, request.ArtifactDigest, findingData.Package, request.FindingId, cancellationToken)
: Task.FromResult<BinaryPatchData?>(null);
if (!request.IncludeBinaryPatch)
{
warnings.Add("Binary patch data disabled by tool policy.");
}
Task<OpsMemoryData?> opsMemoryTask = request.IncludeOpsMemory
? _opsMemoryProvider.GetOpsMemoryDataAsync(
request.TenantId, request.FindingId, findingData.Package, cancellationToken)
: Task.FromResult<OpsMemoryData?>(null);
if (!request.IncludeOpsMemory)
{
warnings.Add("OpsMemory data disabled by tool policy.");
}
await Task.WhenAll(
vexTask, policyTask, provenanceTask, fixTask, contextTask,

View File

@@ -58,6 +58,36 @@ public sealed record EvidenceBundleAssemblyRequest
/// </summary>
public string? PackagePurl { get; init; }
/// <summary>
/// Whether to include SBOM data.
/// </summary>
public bool IncludeSbom { get; init; } = true;
/// <summary>
/// Whether to include VEX data.
/// </summary>
public bool IncludeVex { get; init; } = true;
/// <summary>
/// Whether to include policy evaluations.
/// </summary>
public bool IncludePolicy { get; init; } = true;
/// <summary>
/// Whether to include provenance data.
/// </summary>
public bool IncludeProvenance { get; init; } = true;
/// <summary>
/// Whether to include fix data.
/// </summary>
public bool IncludeFix { get; init; } = true;
/// <summary>
/// Whether to include context data.
/// </summary>
public bool IncludeContext { get; init; } = true;
/// <summary>
/// Whether to include OpsMemory context.
/// </summary>

View File

@@ -0,0 +1,708 @@
// <copyright file="AdvisoryChatAuditEnvelopeBuilder.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Models = StellaOps.AdvisoryAI.Chat.Models;
using StellaOps.AdvisoryAI.Chat.Routing;
using StellaOps.AdvisoryAI.Chat.Services;
using StellaOps.AdvisoryAI.Chat.Settings;
using StellaOps.AdvisoryAI.Guardrails;
using StellaOps.Canonicalization.Json;
namespace StellaOps.AdvisoryAI.Chat.Audit;
internal static class AdvisoryChatAuditEnvelopeBuilder
{
private const string HashPrefix = "sha256:";
private const string DecisionSuccess = "success";
private const string DecisionGuardrailBlocked = "guardrail_blocked";
private const string DecisionQuotaDenied = "quota_denied";
private const string DecisionToolAccessDenied = "tool_access_denied";
public static ChatAuditEnvelope BuildSuccess(
AdvisoryChatRequest request,
IntentRoutingResult routing,
string sanitizedPrompt,
Models.AdvisoryChatEvidenceBundle? evidenceBundle,
Models.AdvisoryChatResponse response,
AdvisoryChatDiagnostics diagnostics,
ChatQuotaStatus? quotaStatus,
ChatToolPolicyResult toolPolicy,
DateTimeOffset now,
bool includeEvidenceBundle)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(routing);
ArgumentNullException.ThrowIfNull(response);
ArgumentNullException.ThrowIfNull(toolPolicy);
var sessionId = !string.IsNullOrWhiteSpace(response.ResponseId)
? response.ResponseId
: ComputeSessionId(request, routing, now);
var promptHash = ComputeHash(sanitizedPrompt);
var (responseJson, responseDigest) = CanonicalJsonSerializer.SerializeWithDigest(response);
var responseHash = HashPrefix + responseDigest;
var modelId = response.Audit?.ModelId;
var modelHash = string.IsNullOrWhiteSpace(modelId) ? null : ComputeHash(modelId);
var totalTokens = diagnostics.PromptTokens + diagnostics.CompletionTokens;
var evidenceBundleJson = includeEvidenceBundle && evidenceBundle is not null
? CanonicalJsonSerializer.Serialize(evidenceBundle)
: null;
var session = new ChatAuditSession
{
SessionId = sessionId,
TenantId = request.TenantId,
UserId = request.UserId,
ConversationId = request.ConversationId,
CorrelationId = request.CorrelationId,
Intent = routing.Intent.ToString(),
Decision = DecisionSuccess,
ModelId = modelId,
ModelHash = modelHash,
PromptHash = promptHash,
ResponseHash = responseHash,
ResponseId = response.ResponseId,
BundleId = response.BundleId,
RedactionsApplied = response.Audit?.RedactionsApplied,
PromptTokens = diagnostics.PromptTokens,
CompletionTokens = diagnostics.CompletionTokens,
TotalTokens = totalTokens,
LatencyMs = diagnostics.TotalMs,
EvidenceBundleJson = evidenceBundleJson,
CreatedAt = now
};
var messages = ImmutableArray.Create(
new ChatAuditMessage
{
MessageId = ComputeId("msg", sessionId, "user", promptHash),
SessionId = sessionId,
Role = "user",
Content = sanitizedPrompt,
ContentHash = promptHash,
CreatedAt = now
},
new ChatAuditMessage
{
MessageId = ComputeId("msg", sessionId, "assistant", responseHash),
SessionId = sessionId,
Role = "assistant",
Content = responseJson,
ContentHash = responseHash,
CreatedAt = now
});
var policyDecisions = BuildPolicyDecisions(
sessionId,
toolPolicy,
quotaStatus,
response,
now);
var toolInputHash = ComputeToolInputHash(request, routing);
var (toolInvocations, evidenceLinks) = BuildEvidenceAudits(
sessionId,
response.EvidenceLinks,
toolInputHash,
now);
return new ChatAuditEnvelope
{
Session = session,
Messages = messages,
PolicyDecisions = policyDecisions,
ToolInvocations = toolInvocations,
EvidenceLinks = evidenceLinks
};
}
public static ChatAuditEnvelope BuildGuardrailBlocked(
AdvisoryChatRequest request,
IntentRoutingResult routing,
string sanitizedPrompt,
AdvisoryGuardrailResult guardrailResult,
Models.AdvisoryChatEvidenceBundle? evidenceBundle,
ChatToolPolicyResult toolPolicy,
ChatQuotaStatus? quotaStatus,
DateTimeOffset now,
bool includeEvidenceBundle)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(routing);
ArgumentNullException.ThrowIfNull(guardrailResult);
ArgumentNullException.ThrowIfNull(toolPolicy);
var sessionId = ComputeSessionId(request, routing, now);
var promptHash = ComputeHash(sanitizedPrompt);
var evidenceBundleJson = includeEvidenceBundle && evidenceBundle is not null
? CanonicalJsonSerializer.Serialize(evidenceBundle)
: null;
var session = new ChatAuditSession
{
SessionId = sessionId,
TenantId = request.TenantId,
UserId = request.UserId,
ConversationId = request.ConversationId,
CorrelationId = request.CorrelationId,
Intent = routing.Intent.ToString(),
Decision = DecisionGuardrailBlocked,
DecisionCode = "GUARDRAIL_BLOCKED",
DecisionReason = guardrailResult.Violations.IsDefaultOrEmpty
? "Guardrail blocked request"
: string.Join("; ", guardrailResult.Violations.Select(v => v.Code)),
PromptHash = promptHash,
RedactionsApplied = ParseRedactionCount(guardrailResult.Metadata),
EvidenceBundleJson = evidenceBundleJson,
CreatedAt = now
};
var messages = ImmutableArray.Create(
new ChatAuditMessage
{
MessageId = ComputeId("msg", sessionId, "user", promptHash),
SessionId = sessionId,
Role = "user",
Content = sanitizedPrompt,
ContentHash = promptHash,
RedactionCount = ParseRedactionCount(guardrailResult.Metadata),
CreatedAt = now
});
var policyDecisions = BuildGuardrailPolicyDecisions(
sessionId,
toolPolicy,
quotaStatus,
guardrailResult,
now);
var toolInputHash = ComputeToolInputHash(request, routing);
var toolInvocations = toolPolicy.AllowedTools
.Select(tool => new ChatAuditToolInvocation
{
InvocationId = ComputeId("tool", sessionId, tool, toolInputHash ?? string.Empty),
SessionId = sessionId,
ToolName = tool,
InputHash = toolInputHash,
OutputHash = null,
PayloadJson = CanonicalJsonSerializer.Serialize(new ToolInvocationPayload
{
ToolName = tool,
EvidenceType = null
}),
InvokedAt = now
})
.ToImmutableArray();
return new ChatAuditEnvelope
{
Session = session,
Messages = messages,
PolicyDecisions = policyDecisions,
ToolInvocations = toolInvocations
};
}
public static ChatAuditEnvelope BuildQuotaDenied(
AdvisoryChatRequest request,
IntentRoutingResult routing,
AdvisoryRedactionResult promptRedaction,
ChatQuotaDecision decision,
ChatToolPolicyResult toolPolicy,
DateTimeOffset now)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(routing);
ArgumentNullException.ThrowIfNull(decision);
ArgumentNullException.ThrowIfNull(toolPolicy);
var sessionId = ComputeSessionId(request, routing, now);
var promptHash = ComputeHash(promptRedaction.Sanitized);
var session = new ChatAuditSession
{
SessionId = sessionId,
TenantId = request.TenantId,
UserId = request.UserId,
ConversationId = request.ConversationId,
CorrelationId = request.CorrelationId,
Intent = routing.Intent.ToString(),
Decision = DecisionQuotaDenied,
DecisionCode = decision.Code,
DecisionReason = decision.Message,
PromptHash = promptHash,
RedactionsApplied = promptRedaction.RedactionCount,
CreatedAt = now
};
var messages = ImmutableArray.Create(
new ChatAuditMessage
{
MessageId = ComputeId("msg", sessionId, "user", promptHash),
SessionId = sessionId,
Role = "user",
Content = promptRedaction.Sanitized,
ContentHash = promptHash,
RedactionCount = promptRedaction.RedactionCount,
CreatedAt = now
});
var policyDecisions = ImmutableArray.Create(
BuildQuotaPolicyDecision(sessionId, "deny", decision, now),
BuildToolPolicyDecision(sessionId, toolPolicy, now));
return new ChatAuditEnvelope
{
Session = session,
Messages = messages,
PolicyDecisions = policyDecisions
};
}
public static ChatAuditEnvelope BuildToolAccessDenied(
AdvisoryChatRequest request,
IntentRoutingResult routing,
AdvisoryRedactionResult promptRedaction,
ChatToolPolicyResult toolPolicy,
string reason,
DateTimeOffset now)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(routing);
ArgumentNullException.ThrowIfNull(toolPolicy);
var sessionId = ComputeSessionId(request, routing, now);
var promptHash = ComputeHash(promptRedaction.Sanitized);
var session = new ChatAuditSession
{
SessionId = sessionId,
TenantId = request.TenantId,
UserId = request.UserId,
ConversationId = request.ConversationId,
CorrelationId = request.CorrelationId,
Intent = routing.Intent.ToString(),
Decision = DecisionToolAccessDenied,
DecisionCode = "TOOL_ACCESS_DENIED",
DecisionReason = reason,
PromptHash = promptHash,
RedactionsApplied = promptRedaction.RedactionCount,
CreatedAt = now
};
var messages = ImmutableArray.Create(
new ChatAuditMessage
{
MessageId = ComputeId("msg", sessionId, "user", promptHash),
SessionId = sessionId,
Role = "user",
Content = promptRedaction.Sanitized,
ContentHash = promptHash,
RedactionCount = promptRedaction.RedactionCount,
CreatedAt = now
});
var policyDecisions = ImmutableArray.Create(
BuildToolPolicyDecision(sessionId, toolPolicy, now) with
{
Decision = "deny",
Reason = reason
});
return new ChatAuditEnvelope
{
Session = session,
Messages = messages,
PolicyDecisions = policyDecisions
};
}
private static ImmutableArray<ChatAuditPolicyDecision> BuildPolicyDecisions(
string sessionId,
ChatToolPolicyResult toolPolicy,
ChatQuotaStatus? quotaStatus,
Models.AdvisoryChatResponse response,
DateTimeOffset now)
{
var builder = ImmutableArray.CreateBuilder<ChatAuditPolicyDecision>();
builder.Add(BuildGuardrailPolicyDecision(sessionId, "allow", null, now));
if (quotaStatus is not null)
{
builder.Add(BuildQuotaPolicyDecision(sessionId, "allow", quotaStatus, now));
}
builder.Add(BuildToolPolicyDecision(sessionId, toolPolicy, now));
foreach (var action in response.ProposedActions)
{
builder.Add(BuildActionPolicyDecision(sessionId, action, now));
}
return builder.ToImmutable();
}
private static ImmutableArray<ChatAuditPolicyDecision> BuildGuardrailPolicyDecisions(
string sessionId,
ChatToolPolicyResult toolPolicy,
ChatQuotaStatus? quotaStatus,
AdvisoryGuardrailResult guardrailResult,
DateTimeOffset now)
{
var builder = ImmutableArray.CreateBuilder<ChatAuditPolicyDecision>();
builder.Add(BuildGuardrailPolicyDecision(sessionId, "deny", guardrailResult, now));
if (quotaStatus is not null)
{
builder.Add(BuildQuotaPolicyDecision(sessionId, "allow", quotaStatus, now));
}
builder.Add(BuildToolPolicyDecision(sessionId, toolPolicy, now));
return builder.ToImmutable();
}
private static ChatAuditPolicyDecision BuildGuardrailPolicyDecision(
string sessionId,
string decision,
AdvisoryGuardrailResult? guardrailResult,
DateTimeOffset now)
{
string? payloadJson = null;
string? reason = null;
if (guardrailResult is not null)
{
var payload = new GuardrailDecisionPayload
{
Violations = guardrailResult.Violations
.Select(v => new GuardrailViolationPayload
{
Code = v.Code,
Message = v.Message
})
.ToImmutableArray(),
Metadata = guardrailResult.Metadata
};
payloadJson = CanonicalJsonSerializer.Serialize(payload);
reason = guardrailResult.Violations.IsDefaultOrEmpty
? "Guardrail blocked request"
: string.Join("; ", guardrailResult.Violations.Select(v => v.Code));
}
return new ChatAuditPolicyDecision
{
DecisionId = ComputeId("pol", sessionId, "guardrail", decision),
SessionId = sessionId,
PolicyType = "guardrail",
Decision = decision,
Reason = reason,
PayloadJson = payloadJson,
CreatedAt = now
};
}
private static ChatAuditPolicyDecision BuildQuotaPolicyDecision(
string sessionId,
string decision,
ChatQuotaStatus quotaStatus,
DateTimeOffset now)
{
var payloadJson = CanonicalJsonSerializer.Serialize(quotaStatus);
return new ChatAuditPolicyDecision
{
DecisionId = ComputeId("pol", sessionId, "quota", decision),
SessionId = sessionId,
PolicyType = "quota",
Decision = decision,
PayloadJson = payloadJson,
CreatedAt = now
};
}
private static ChatAuditPolicyDecision BuildQuotaPolicyDecision(
string sessionId,
string decision,
ChatQuotaDecision quotaDecision,
DateTimeOffset now)
{
var payloadJson = CanonicalJsonSerializer.Serialize(quotaDecision.Status);
return new ChatAuditPolicyDecision
{
DecisionId = ComputeId("pol", sessionId, "quota", decision),
SessionId = sessionId,
PolicyType = "quota",
Decision = decision,
Reason = quotaDecision.Message,
PayloadJson = payloadJson,
CreatedAt = now
};
}
private static ChatAuditPolicyDecision BuildToolPolicyDecision(
string sessionId,
ChatToolPolicyResult toolPolicy,
DateTimeOffset now)
{
var payload = new ToolPolicyAuditPayload
{
AllowAll = toolPolicy.AllowAll,
AllowedTools = toolPolicy.AllowedTools,
Providers = new ToolProviderPayload
{
Sbom = toolPolicy.AllowSbom,
Vex = toolPolicy.AllowVex,
Reachability = toolPolicy.AllowReachability,
BinaryPatch = toolPolicy.AllowBinaryPatch,
OpsMemory = toolPolicy.AllowOpsMemory,
Policy = toolPolicy.AllowPolicy,
Provenance = toolPolicy.AllowProvenance,
Fix = toolPolicy.AllowFix,
Context = toolPolicy.AllowContext
},
ToolCalls = toolPolicy.ToolCallCount
};
return new ChatAuditPolicyDecision
{
DecisionId = ComputeId("pol", sessionId, "tool_access", "allow"),
SessionId = sessionId,
PolicyType = "tool_access",
Decision = "allow",
PayloadJson = CanonicalJsonSerializer.Serialize(payload),
CreatedAt = now
};
}
private static ChatAuditPolicyDecision BuildActionPolicyDecision(
string sessionId,
Models.ProposedAction action,
DateTimeOffset now)
{
var requiresApproval = action.RequiresApproval ?? false;
var decision = requiresApproval ? "approval_required" : "allow";
var payload = new ActionPolicyPayload
{
ActionId = action.ActionId,
ActionType = action.ActionType.ToString(),
RequiresApproval = requiresApproval,
RiskLevel = action.RiskLevel?.ToString()
};
return new ChatAuditPolicyDecision
{
DecisionId = ComputeId("pol", sessionId, "action", action.ActionId, decision),
SessionId = sessionId,
PolicyType = "action",
Decision = decision,
PayloadJson = CanonicalJsonSerializer.Serialize(payload),
CreatedAt = now
};
}
private static (ImmutableArray<ChatAuditToolInvocation> ToolInvocations, ImmutableArray<ChatAuditEvidenceLink> EvidenceLinks)
BuildEvidenceAudits(
string sessionId,
ImmutableArray<Models.EvidenceLink> evidenceLinks,
string? toolInputHash,
DateTimeOffset now)
{
if (evidenceLinks.IsDefaultOrEmpty)
{
return (ImmutableArray<ChatAuditToolInvocation>.Empty, ImmutableArray<ChatAuditEvidenceLink>.Empty);
}
var toolBuilder = ImmutableArray.CreateBuilder<ChatAuditToolInvocation>();
var linkBuilder = ImmutableArray.CreateBuilder<ChatAuditEvidenceLink>();
foreach (var link in evidenceLinks)
{
var payload = new EvidenceLinkPayload
{
Type = link.Type.ToString(),
Link = link.Link,
Description = link.Description,
Confidence = link.Confidence?.ToString()
};
var (payloadJson, payloadDigest) = CanonicalJsonSerializer.SerializeWithDigest(payload);
var linkHash = HashPrefix + payloadDigest;
linkBuilder.Add(new ChatAuditEvidenceLink
{
LinkId = ComputeId("link", sessionId, linkHash),
SessionId = sessionId,
LinkType = payload.Type,
Link = payload.Link,
Description = payload.Description,
Confidence = payload.Confidence,
LinkHash = linkHash,
CreatedAt = now
});
var toolName = MapToolName(link.Type);
toolBuilder.Add(new ChatAuditToolInvocation
{
InvocationId = ComputeId("tool", sessionId, toolName, linkHash),
SessionId = sessionId,
ToolName = toolName,
InputHash = toolInputHash,
OutputHash = linkHash,
PayloadJson = payloadJson,
InvokedAt = now
});
}
return (toolBuilder.ToImmutable(), linkBuilder.ToImmutable());
}
private static string MapToolName(Models.EvidenceLinkType type)
=> type switch
{
Models.EvidenceLinkType.Sbom => "sbom.read",
Models.EvidenceLinkType.Vex => "vex.query",
Models.EvidenceLinkType.Reach => "reachability.graph.query",
Models.EvidenceLinkType.Binpatch => "binary.patch.detect",
Models.EvidenceLinkType.Attest => "provenance.read",
Models.EvidenceLinkType.Policy => "policy.eval",
Models.EvidenceLinkType.Runtime => "context.read",
Models.EvidenceLinkType.Opsmem => "opsmemory.read",
_ => "context.read"
};
private static string ComputeToolInputHash(
AdvisoryChatRequest request,
IntentRoutingResult routing)
{
var payload = new ToolInputPayload
{
TenantId = request.TenantId,
UserId = request.UserId,
ArtifactDigest = request.ArtifactDigest,
ImageReference = request.ImageReference ?? routing.Parameters.ImageReference,
Environment = request.Environment,
FindingId = routing.Parameters.FindingId,
Package = routing.Parameters.Package
};
var (_, digest) = CanonicalJsonSerializer.SerializeWithDigest(payload);
return HashPrefix + digest;
}
private static string ComputeSessionId(
AdvisoryChatRequest request,
IntentRoutingResult routing,
DateTimeOffset now)
{
var stamp = now.ToString("O", CultureInfo.InvariantCulture);
return ComputeId("chat", request.TenantId, request.UserId, routing.Intent.ToString(), routing.NormalizedInput, stamp);
}
private static string ComputeId(string prefix, params string[] parts)
{
var input = string.Join("|", parts.Select(p => p ?? string.Empty));
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
var digest = Convert.ToHexStringLower(hash)[..16];
return $"{prefix}-{digest}";
}
private static string ComputeHash(string value)
{
if (string.IsNullOrEmpty(value))
{
return HashPrefix + "0".PadLeft(64, '0');
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return HashPrefix + Convert.ToHexStringLower(hash);
}
private static int? ParseRedactionCount(ImmutableDictionary<string, string> metadata)
{
if (metadata is null || metadata.Count == 0)
{
return null;
}
if (metadata.TryGetValue("redaction_count", out var value) &&
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count))
{
return count;
}
return null;
}
private sealed record GuardrailViolationPayload
{
public required string Code { get; init; }
public required string Message { get; init; }
}
private sealed record GuardrailDecisionPayload
{
public ImmutableArray<GuardrailViolationPayload> Violations { get; init; } =
ImmutableArray<GuardrailViolationPayload>.Empty;
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
private sealed record ToolPolicyAuditPayload
{
public required bool AllowAll { get; init; }
public required ImmutableArray<string> AllowedTools { get; init; }
public required ToolProviderPayload Providers { get; init; }
public required int ToolCalls { get; init; }
}
private sealed record ToolProviderPayload
{
public required bool Sbom { get; init; }
public required bool Vex { get; init; }
public required bool Reachability { get; init; }
public required bool BinaryPatch { get; init; }
public required bool OpsMemory { get; init; }
public required bool Policy { get; init; }
public required bool Provenance { get; init; }
public required bool Fix { get; init; }
public required bool Context { get; init; }
}
private sealed record ActionPolicyPayload
{
public required string ActionId { get; init; }
public required string ActionType { get; init; }
public required bool RequiresApproval { get; init; }
public string? RiskLevel { get; init; }
}
private sealed record EvidenceLinkPayload
{
public required string Type { get; init; }
public required string Link { get; init; }
public string? Description { get; init; }
public string? Confidence { get; init; }
}
private sealed record ToolInvocationPayload
{
public required string ToolName { get; init; }
public string? EvidenceType { get; init; }
}
private sealed record ToolInputPayload
{
public required string TenantId { get; init; }
public required string UserId { get; init; }
public string? ArtifactDigest { get; init; }
public string? ImageReference { get; init; }
public string? Environment { get; init; }
public string? FindingId { get; init; }
public string? Package { get; init; }
}
}

View File

@@ -0,0 +1,89 @@
// <copyright file="ChatAuditRecords.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Chat.Audit;
internal sealed record ChatAuditEnvelope
{
public required ChatAuditSession Session { get; init; }
public ImmutableArray<ChatAuditMessage> Messages { get; init; } = ImmutableArray<ChatAuditMessage>.Empty;
public ImmutableArray<ChatAuditPolicyDecision> PolicyDecisions { get; init; } =
ImmutableArray<ChatAuditPolicyDecision>.Empty;
public ImmutableArray<ChatAuditToolInvocation> ToolInvocations { get; init; } =
ImmutableArray<ChatAuditToolInvocation>.Empty;
public ImmutableArray<ChatAuditEvidenceLink> EvidenceLinks { get; init; } =
ImmutableArray<ChatAuditEvidenceLink>.Empty;
}
internal sealed record ChatAuditSession
{
public required string SessionId { get; init; }
public required string TenantId { get; init; }
public required string UserId { get; init; }
public string? ConversationId { get; init; }
public string? CorrelationId { get; init; }
public string? Intent { get; init; }
public required string Decision { get; init; }
public string? DecisionCode { get; init; }
public string? DecisionReason { get; init; }
public string? ModelId { get; init; }
public string? ModelHash { get; init; }
public string? PromptHash { get; init; }
public string? ResponseHash { get; init; }
public string? ResponseId { get; init; }
public string? BundleId { get; init; }
public int? RedactionsApplied { get; init; }
public int? PromptTokens { get; init; }
public int? CompletionTokens { get; init; }
public int? TotalTokens { get; init; }
public long? LatencyMs { get; init; }
public string? EvidenceBundleJson { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
}
internal sealed record ChatAuditMessage
{
public required string MessageId { get; init; }
public required string SessionId { get; init; }
public required string Role { get; init; }
public required string Content { get; init; }
public required string ContentHash { get; init; }
public int? RedactionCount { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
}
internal sealed record ChatAuditPolicyDecision
{
public required string DecisionId { get; init; }
public required string SessionId { get; init; }
public required string PolicyType { get; init; }
public required string Decision { get; init; }
public string? Reason { get; init; }
public string? PayloadJson { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
}
internal sealed record ChatAuditToolInvocation
{
public required string InvocationId { get; init; }
public required string SessionId { get; init; }
public required string ToolName { get; init; }
public string? InputHash { get; init; }
public string? OutputHash { get; init; }
public string? PayloadJson { get; init; }
public required DateTimeOffset InvokedAt { get; init; }
}
internal sealed record ChatAuditEvidenceLink
{
public required string LinkId { get; init; }
public required string SessionId { get; init; }
public required string LinkType { get; init; }
public required string Link { get; init; }
public string? Description { get; init; }
public string? Confidence { get; init; }
public required string LinkHash { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -12,6 +12,7 @@ using StellaOps.AdvisoryAI.Chat.Inference;
using StellaOps.AdvisoryAI.Chat.Options;
using StellaOps.AdvisoryAI.Chat.Routing;
using StellaOps.AdvisoryAI.Chat.Services;
using StellaOps.AdvisoryAI.Chat.Settings;
namespace Microsoft.Extensions.DependencyInjection;
@@ -65,6 +66,22 @@ public static class AdvisoryChatServiceCollectionExtensions
// Intent routing
services.TryAddSingleton<IAdvisoryChatIntentRouter, AdvisoryChatIntentRouter>();
// Settings, quotas, and audit
services.TryAddSingleton<IAdvisoryChatSettingsStore, InMemoryAdvisoryChatSettingsStore>();
services.TryAddSingleton<IAdvisoryChatSettingsService, AdvisoryChatSettingsService>();
services.TryAddSingleton<IAdvisoryChatQuotaService, AdvisoryChatQuotaService>();
services.TryAddSingleton<IAdvisoryChatAuditLogger>(sp =>
{
var options = sp.GetRequiredService<IOptions<AdvisoryChatOptions>>().Value;
if (options.Audit.Enabled && !string.IsNullOrWhiteSpace(options.Audit.ConnectionString))
{
return ActivatorUtilities.CreateInstance<PostgresAdvisoryChatAuditLogger>(sp);
}
return new NullAdvisoryChatAuditLogger();
});
services.TryAddSingleton<IAdvisoryInferenceClient, LocalChatInferenceClient>();
// Evidence assembly
services.TryAddScoped<IEvidenceBundleAssembler, EvidenceBundleAssembler>();

View File

@@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Evidence.Pack;
using StellaOps.Evidence.Pack.Models;
using EvidenceClaimType = StellaOps.Evidence.Pack.Models.ClaimType;
namespace StellaOps.AdvisoryAI.Chat;
@@ -151,10 +152,10 @@ public sealed class EvidencePackChatIntegration
// Determine claim type based on link type
var claimType = link.Type switch
{
"vex" => Evidence.Pack.Models.ClaimType.VulnerabilityStatus,
"reach" or "runtime" => Evidence.Pack.Models.ClaimType.Reachability,
"sbom" => Evidence.Pack.Models.ClaimType.VulnerabilityStatus,
_ => Evidence.Pack.Models.ClaimType.Custom
"vex" => EvidenceClaimType.VulnerabilityStatus,
"reach" or "runtime" => EvidenceClaimType.Reachability,
"sbom" => EvidenceClaimType.VulnerabilityStatus,
_ => EvidenceClaimType.Custom
};
// Build claim text based on link context

View File

@@ -14,18 +14,18 @@ namespace StellaOps.AdvisoryAI.Chat;
/// </summary>
public sealed class OpsMemoryLinkResolver : ITypedLinkResolver
{
private readonly IOpsMemoryStore _store;
private readonly IOpsMemoryStore? _store;
private readonly ILogger<OpsMemoryLinkResolver> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="OpsMemoryLinkResolver"/> class.
/// </summary>
public OpsMemoryLinkResolver(
IOpsMemoryStore store,
ILogger<OpsMemoryLinkResolver> logger)
ILogger<OpsMemoryLinkResolver> logger,
IOpsMemoryStore? store = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_store = store;
}
/// <summary>
@@ -45,6 +45,12 @@ public sealed class OpsMemoryLinkResolver : ITypedLinkResolver
return new LinkResolution { Exists = false };
}
if (_store is null)
{
_logger.LogDebug("OpsMemory store not configured; skipping ops-mem link resolution.");
return new LinkResolution { Exists = false };
}
try
{
var record = await _store.GetByIdAsync(path, tenantId, cancellationToken)

View File

@@ -3,6 +3,7 @@
// </copyright>
using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
using Microsoft.Extensions.Options;
namespace StellaOps.AdvisoryAI.Chat.Options;
@@ -38,6 +39,16 @@ public sealed class AdvisoryChatOptions
/// </summary>
public GuardrailOptions Guardrails { get; set; } = new();
/// <summary>
/// Quota defaults for chat usage.
/// </summary>
public QuotaOptions Quotas { get; set; } = new();
/// <summary>
/// Tool access defaults for chat usage.
/// </summary>
public ToolAccessOptions Tools { get; set; } = new();
/// <summary>
/// Audit logging configuration.
/// </summary>
@@ -179,6 +190,48 @@ public sealed class GuardrailOptions
public bool BlockHarmfulPrompts { get; set; } = true;
}
/// <summary>
/// Quota defaults for chat usage.
/// </summary>
public sealed class QuotaOptions
{
/// <summary>
/// Requests per minute (0 disables the limit).
/// </summary>
public int RequestsPerMinute { get; set; } = 60;
/// <summary>
/// Requests per day (0 disables the limit).
/// </summary>
public int RequestsPerDay { get; set; } = 500;
/// <summary>
/// Tokens per day (0 disables the limit).
/// </summary>
public int TokensPerDay { get; set; } = 100000;
/// <summary>
/// Tool calls per day (0 disables the limit).
/// </summary>
public int ToolCallsPerDay { get; set; } = 10000;
}
/// <summary>
/// Tool access defaults for chat usage.
/// </summary>
public sealed class ToolAccessOptions
{
/// <summary>
/// Allow all tools when true, otherwise use AllowedTools.
/// </summary>
public bool AllowAll { get; set; } = true;
/// <summary>
/// Allowed tools when AllowAll is false.
/// </summary>
public List<string> AllowedTools { get; set; } = new();
}
/// <summary>
/// Audit logging configuration.
/// </summary>
@@ -189,6 +242,16 @@ public sealed class AuditOptions
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Connection string for audit persistence (Postgres).
/// </summary>
public string? ConnectionString { get; set; }
/// <summary>
/// Schema name for audit tables.
/// </summary>
public string SchemaName { get; set; } = "advisoryai";
/// <summary>
/// Include full evidence bundle in audit log.
/// </summary>
@@ -236,6 +299,26 @@ internal sealed class AdvisoryChatOptionsValidator : IValidateOptions<AdvisoryCh
{
errors.Add("Inference.Temperature must be between 0.0 and 1.0");
}
if (options.Quotas.RequestsPerMinute < 0)
{
errors.Add("Quotas.RequestsPerMinute must be >= 0");
}
if (options.Quotas.RequestsPerDay < 0)
{
errors.Add("Quotas.RequestsPerDay must be >= 0");
}
if (options.Quotas.TokensPerDay < 0)
{
errors.Add("Quotas.TokensPerDay must be >= 0");
}
if (options.Quotas.ToolCallsPerDay < 0)
{
errors.Add("Quotas.ToolCallsPerDay must be >= 0");
}
}
return errors.Count > 0

View File

@@ -0,0 +1,307 @@
// <copyright file="AdvisoryChatQuotaService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using StellaOps.AdvisoryAI.Chat.Settings;
namespace StellaOps.AdvisoryAI.Chat.Services;
/// <summary>
/// Request for quota evaluation.
/// </summary>
public sealed record ChatQuotaRequest
{
public required string TenantId { get; init; }
public required string UserId { get; init; }
public int EstimatedTokens { get; init; }
public int ToolCalls { get; init; }
}
/// <summary>
/// Quota evaluation decision.
/// </summary>
public sealed record ChatQuotaDecision
{
public required bool Allowed { get; init; }
public string? Code { get; init; }
public string? Message { get; init; }
public required ChatQuotaStatus Status { get; init; }
}
/// <summary>
/// Quota status snapshot for doctor output.
/// </summary>
public sealed record ChatQuotaStatus
{
public required int RequestsPerMinuteLimit { get; init; }
public required int RequestsPerMinuteRemaining { get; init; }
public required DateTimeOffset RequestsPerMinuteResetsAt { get; init; }
public required int RequestsPerDayLimit { get; init; }
public required int RequestsPerDayRemaining { get; init; }
public required DateTimeOffset RequestsPerDayResetsAt { get; init; }
public required int TokensPerDayLimit { get; init; }
public required int TokensPerDayRemaining { get; init; }
public required DateTimeOffset TokensPerDayResetsAt { get; init; }
public required int ToolCallsPerDayLimit { get; init; }
public required int ToolCallsPerDayRemaining { get; init; }
public required DateTimeOffset ToolCallsPerDayResetsAt { get; init; }
public ChatQuotaDenial? LastDenied { get; init; }
}
/// <summary>
/// Denial record for doctor output.
/// </summary>
public sealed record ChatQuotaDenial
{
public required string Code { get; init; }
public required string Message { get; init; }
public required DateTimeOffset DeniedAt { get; init; }
}
/// <summary>
/// Quota service for chat requests.
/// </summary>
public interface IAdvisoryChatQuotaService
{
Task<ChatQuotaDecision> TryConsumeAsync(
ChatQuotaRequest request,
ChatQuotaSettings settings,
CancellationToken cancellationToken = default);
ChatQuotaStatus GetStatus(
string tenantId,
string userId,
ChatQuotaSettings settings);
ChatQuotaDenial? GetLastDenial(string tenantId, string userId);
}
/// <summary>
/// In-memory quota service with fixed minute/day windows.
/// </summary>
public sealed class AdvisoryChatQuotaService : IAdvisoryChatQuotaService
{
private readonly Dictionary<string, ChatQuotaState> _states = new();
private readonly object _lock = new();
private readonly TimeProvider _timeProvider;
public AdvisoryChatQuotaService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<ChatQuotaDecision> TryConsumeAsync(
ChatQuotaRequest request,
ChatQuotaSettings settings,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(settings);
var now = _timeProvider.GetUtcNow();
var normalizedTokens = Math.Max(0, request.EstimatedTokens);
var normalizedToolCalls = Math.Max(0, request.ToolCalls);
lock (_lock)
{
var state = GetState(request.TenantId, request.UserId, now);
ResetWindowsIfNeeded(state, now);
if (settings.RequestsPerMinute > 0 && state.MinuteCount + 1 > settings.RequestsPerMinute)
{
var decision = Deny(state, now, "REQUESTS_PER_MINUTE_EXCEEDED", "Request rate limit exceeded.");
return Task.FromResult(decision with { Status = BuildStatus(state, settings, now) });
}
if (settings.RequestsPerDay > 0 && state.DayCount + 1 > settings.RequestsPerDay)
{
var decision = Deny(state, now, "REQUESTS_PER_DAY_EXCEEDED", "Daily request quota exceeded.");
return Task.FromResult(decision with { Status = BuildStatus(state, settings, now) });
}
if (settings.TokensPerDay > 0 && state.DayTokens + normalizedTokens > settings.TokensPerDay)
{
var decision = Deny(state, now, "TOKENS_PER_DAY_EXCEEDED", "Daily token quota exceeded.");
return Task.FromResult(decision with { Status = BuildStatus(state, settings, now) });
}
if (settings.ToolCallsPerDay > 0 && state.DayToolCalls + normalizedToolCalls > settings.ToolCallsPerDay)
{
var decision = Deny(state, now, "TOOL_CALLS_PER_DAY_EXCEEDED", "Daily tool call quota exceeded.");
return Task.FromResult(decision with { Status = BuildStatus(state, settings, now) });
}
state.MinuteCount++;
state.DayCount++;
state.DayTokens += normalizedTokens;
state.DayToolCalls += normalizedToolCalls;
var allowed = new ChatQuotaDecision
{
Allowed = true,
Status = BuildStatus(state, settings, now)
};
return Task.FromResult(allowed);
}
}
public ChatQuotaStatus GetStatus(
string tenantId,
string userId,
ChatQuotaSettings settings)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
ArgumentNullException.ThrowIfNull(settings);
var now = _timeProvider.GetUtcNow();
lock (_lock)
{
var state = GetState(tenantId, userId, now);
ResetWindowsIfNeeded(state, now);
return BuildStatus(state, settings, now);
}
}
public ChatQuotaDenial? GetLastDenial(string tenantId, string userId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
var now = _timeProvider.GetUtcNow();
lock (_lock)
{
var state = GetState(tenantId, userId, now);
return state.LastDenied;
}
}
private ChatQuotaDecision Deny(ChatQuotaState state, DateTimeOffset now, string code, string message)
{
var denial = new ChatQuotaDenial
{
Code = code,
Message = message,
DeniedAt = now
};
state.LastDenied = denial;
return new ChatQuotaDecision
{
Allowed = false,
Code = code,
Message = message,
Status = BuildStatus(state, state.LastSettingsSnapshot ?? new ChatQuotaSettings(), now)
};
}
private static DateTimeOffset TruncateToMinute(DateTimeOffset timestamp)
{
return new DateTimeOffset(
timestamp.Year,
timestamp.Month,
timestamp.Day,
timestamp.Hour,
timestamp.Minute,
0,
TimeSpan.Zero);
}
private static DateTimeOffset TruncateToDay(DateTimeOffset timestamp)
{
return new DateTimeOffset(
timestamp.Year,
timestamp.Month,
timestamp.Day,
0,
0,
0,
TimeSpan.Zero);
}
private static void ResetWindowsIfNeeded(ChatQuotaState state, DateTimeOffset now)
{
var minuteWindow = TruncateToMinute(now);
if (state.MinuteWindowStart != minuteWindow)
{
state.MinuteWindowStart = minuteWindow;
state.MinuteCount = 0;
}
var dayWindow = TruncateToDay(now);
if (state.DayWindowStart != dayWindow)
{
state.DayWindowStart = dayWindow;
state.DayCount = 0;
state.DayTokens = 0;
state.DayToolCalls = 0;
}
}
private ChatQuotaState GetState(string tenantId, string userId, DateTimeOffset now)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
var key = $"{tenantId}:{userId}";
if (!_states.TryGetValue(key, out var state))
{
state = new ChatQuotaState
{
MinuteWindowStart = TruncateToMinute(now),
DayWindowStart = TruncateToDay(now)
};
_states[key] = state;
}
return state;
}
private static ChatQuotaStatus BuildStatus(
ChatQuotaState state,
ChatQuotaSettings settings,
DateTimeOffset now)
{
var minuteReset = TruncateToMinute(now).AddMinutes(1);
var dayReset = TruncateToDay(now).AddDays(1);
state.LastSettingsSnapshot = settings;
return new ChatQuotaStatus
{
RequestsPerMinuteLimit = settings.RequestsPerMinute,
RequestsPerMinuteRemaining = ComputeRemaining(settings.RequestsPerMinute, state.MinuteCount),
RequestsPerMinuteResetsAt = minuteReset,
RequestsPerDayLimit = settings.RequestsPerDay,
RequestsPerDayRemaining = ComputeRemaining(settings.RequestsPerDay, state.DayCount),
RequestsPerDayResetsAt = dayReset,
TokensPerDayLimit = settings.TokensPerDay,
TokensPerDayRemaining = ComputeRemaining(settings.TokensPerDay, state.DayTokens),
TokensPerDayResetsAt = dayReset,
ToolCallsPerDayLimit = settings.ToolCallsPerDay,
ToolCallsPerDayRemaining = ComputeRemaining(settings.ToolCallsPerDay, state.DayToolCalls),
ToolCallsPerDayResetsAt = dayReset,
LastDenied = state.LastDenied
};
}
private static int ComputeRemaining(int limit, int used)
{
if (limit <= 0)
{
return limit;
}
return Math.Max(0, limit - used);
}
private sealed class ChatQuotaState
{
public DateTimeOffset MinuteWindowStart { get; set; }
public int MinuteCount { get; set; }
public DateTimeOffset DayWindowStart { get; set; }
public int DayCount { get; set; }
public int DayTokens { get; set; }
public int DayToolCalls { get; set; }
public ChatQuotaDenial? LastDenied { get; set; }
public ChatQuotaSettings? LastSettingsSnapshot { get; set; }
}
}

View File

@@ -4,6 +4,7 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -11,7 +12,9 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Actions;
using StellaOps.AdvisoryAI.Chat.Assembly;
using StellaOps.AdvisoryAI.Chat.Options;
using StellaOps.AdvisoryAI.Chat.Routing;
using StellaOps.AdvisoryAI.Chat.Settings;
using StellaOps.AdvisoryAI.Guardrails;
using StellaOps.AdvisoryAI.Prompting;
@@ -129,6 +132,31 @@ public sealed record AdvisoryChatServiceResult
/// </summary>
public ImmutableArray<string> GuardrailViolations { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Whether quota enforcement blocked the request.
/// </summary>
public bool QuotaBlocked { get; init; }
/// <summary>
/// Quota decision code if blocked.
/// </summary>
public string? QuotaCode { get; init; }
/// <summary>
/// Quota status snapshot.
/// </summary>
public ChatQuotaStatus? QuotaStatus { get; init; }
/// <summary>
/// Whether tool access policy blocked the request.
/// </summary>
public bool ToolAccessDenied { get; init; }
/// <summary>
/// Tool access reason if blocked.
/// </summary>
public string? ToolAccessReason { get; init; }
/// <summary>
/// Processing diagnostics.
/// </summary>
@@ -161,9 +189,12 @@ internal sealed class AdvisoryChatService : IAdvisoryChatService
private readonly IAdvisoryInferenceClient _inferenceClient;
private readonly IActionPolicyGate _policyGate;
private readonly IAdvisoryChatAuditLogger _auditLogger;
private readonly IAdvisoryChatSettingsService _settingsService;
private readonly IAdvisoryChatQuotaService _quotaService;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AdvisoryChatService> _logger;
private readonly AdvisoryChatServiceOptions _options;
private readonly AdvisoryChatOptions _chatOptions;
public AdvisoryChatService(
IAdvisoryChatIntentRouter intentRouter,
@@ -172,8 +203,11 @@ internal sealed class AdvisoryChatService : IAdvisoryChatService
IAdvisoryInferenceClient inferenceClient,
IActionPolicyGate policyGate,
IAdvisoryChatAuditLogger auditLogger,
IAdvisoryChatSettingsService settingsService,
IAdvisoryChatQuotaService quotaService,
TimeProvider timeProvider,
IOptions<AdvisoryChatServiceOptions> options,
IOptions<AdvisoryChatOptions> chatOptions,
ILogger<AdvisoryChatService> logger)
{
_intentRouter = intentRouter ?? throw new ArgumentNullException(nameof(intentRouter));
@@ -182,8 +216,11 @@ internal sealed class AdvisoryChatService : IAdvisoryChatService
_inferenceClient = inferenceClient ?? throw new ArgumentNullException(nameof(inferenceClient));
_policyGate = policyGate ?? throw new ArgumentNullException(nameof(policyGate));
_auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
_quotaService = quotaService ?? throw new ArgumentNullException(nameof(quotaService));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_options = options?.Value ?? new AdvisoryChatServiceOptions();
_chatOptions = chatOptions?.Value ?? new AdvisoryChatOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -219,6 +256,74 @@ internal sealed class AdvisoryChatService : IAdvisoryChatService
return CreateMissingContextResult(routingResult.Intent, artifactDigest, findingId);
}
var settings = await _settingsService.GetEffectiveSettingsAsync(
request.TenantId,
request.UserId,
cancellationToken);
var toolPolicy = AdvisoryChatToolPolicy.Resolve(
settings.Tools,
_chatOptions.DataProviders,
includeReachability: true,
includeBinaryPatch: true,
includeOpsMemory: true);
if (!toolPolicy.AllowSbom)
{
var promptRedaction = _guardrails.Redact(request.Query);
await _auditLogger.LogToolAccessDeniedAsync(
request,
routingResult,
promptRedaction,
toolPolicy,
"sbom.read not allowed by settings",
cancellationToken);
return new AdvisoryChatServiceResult
{
Success = false,
Error = "Tool access denied: sbom.read",
Intent = routingResult.Intent,
EvidenceAssembled = false,
ToolAccessDenied = true,
ToolAccessReason = "sbom.read not allowed by settings"
};
}
var quotaDecision = await _quotaService.TryConsumeAsync(
new ChatQuotaRequest
{
TenantId = request.TenantId,
UserId = request.UserId,
EstimatedTokens = _options.MaxCompletionTokens,
ToolCalls = toolPolicy.ToolCallCount
},
settings.Quotas,
cancellationToken);
if (!quotaDecision.Allowed)
{
var promptRedaction = _guardrails.Redact(request.Query);
await _auditLogger.LogQuotaDeniedAsync(
request,
routingResult,
promptRedaction,
quotaDecision,
toolPolicy,
cancellationToken);
return new AdvisoryChatServiceResult
{
Success = false,
Error = quotaDecision.Message ?? "Quota exceeded",
Intent = routingResult.Intent,
EvidenceAssembled = false,
QuotaBlocked = true,
QuotaCode = quotaDecision.Code,
QuotaStatus = quotaDecision.Status
};
}
// Phase 3: Assemble evidence bundle
var assemblyStopwatch = Stopwatch.StartNew();
var assemblyResult = await _evidenceAssembler.AssembleAsync(
@@ -230,6 +335,15 @@ internal sealed class AdvisoryChatService : IAdvisoryChatService
Environment = environment ?? "unknown",
FindingId = findingId,
PackagePurl = routingResult.Parameters.Package,
IncludeSbom = toolPolicy.AllowSbom,
IncludeVex = toolPolicy.AllowVex,
IncludePolicy = toolPolicy.AllowPolicy,
IncludeProvenance = toolPolicy.AllowProvenance,
IncludeFix = toolPolicy.AllowFix,
IncludeContext = toolPolicy.AllowContext,
IncludeReachability = toolPolicy.AllowReachability,
IncludeBinaryPatch = toolPolicy.AllowBinaryPatch,
IncludeOpsMemory = toolPolicy.AllowOpsMemory,
CorrelationId = request.CorrelationId
},
cancellationToken);
@@ -251,13 +365,22 @@ internal sealed class AdvisoryChatService : IAdvisoryChatService
var prompt = BuildPrompt(assemblyResult.Bundle, routingResult);
var guardrailResult = await _guardrails.EvaluateAsync(prompt, cancellationToken);
diagnostics.GuardrailEvaluationMs = guardrailStopwatch.ElapsedMilliseconds;
var inputRedactionCount = GetRedactionCount(guardrailResult.Metadata);
if (guardrailResult.Blocked)
{
_logger.LogWarning("Guardrails blocked query: {Violations}",
string.Join(", ", guardrailResult.Violations.Select(v => v.Code)));
await _auditLogger.LogBlockedAsync(request, routingResult, guardrailResult, cancellationToken);
await _auditLogger.LogBlockedAsync(
request,
routingResult,
guardrailResult,
guardrailResult.SanitizedPrompt,
assemblyResult.Bundle,
toolPolicy,
quotaDecision.Status,
cancellationToken);
return new AdvisoryChatServiceResult
{
@@ -285,8 +408,9 @@ internal sealed class AdvisoryChatService : IAdvisoryChatService
diagnostics.CompletionTokens = inferenceResult.CompletionTokens;
// Phase 6: Parse and validate response
var outputRedaction = _guardrails.Redact(inferenceResult.Completion);
var response = ParseInferenceResponse(
inferenceResult.Completion,
outputRedaction.Sanitized,
assemblyResult.Bundle,
routingResult.Intent);
@@ -296,11 +420,29 @@ internal sealed class AdvisoryChatService : IAdvisoryChatService
response, request, cancellationToken);
diagnostics.PolicyGateMs = policyStopwatch.ElapsedMilliseconds;
response = response with
{
Audit = (response.Audit ?? new Models.ResponseAudit()) with
{
RedactionsApplied = inputRedactionCount + outputRedaction.RedactionCount
}
};
totalStopwatch.Stop();
diagnostics.TotalMs = totalStopwatch.ElapsedMilliseconds;
var finalDiagnostics = diagnostics.Build();
// Audit successful interaction
await _auditLogger.LogSuccessAsync(request, routingResult, response, diagnostics.Build(), cancellationToken);
await _auditLogger.LogSuccessAsync(
request,
routingResult,
guardrailResult.SanitizedPrompt,
assemblyResult.Bundle,
response,
finalDiagnostics,
toolPolicy,
quotaDecision.Status,
cancellationToken);
_logger.LogInformation(
"Advisory chat completed in {TotalMs}ms: {Intent} with {EvidenceLinks} evidence links",
@@ -312,7 +454,7 @@ internal sealed class AdvisoryChatService : IAdvisoryChatService
Response = response,
Intent = routingResult.Intent,
EvidenceAssembled = true,
Diagnostics = diagnostics.Build()
Diagnostics = finalDiagnostics
};
}
catch (Exception ex)
@@ -354,6 +496,17 @@ internal sealed class AdvisoryChatService : IAdvisoryChatService
return null;
}
private static int GetRedactionCount(ImmutableDictionary<string, string> metadata)
{
if (metadata.TryGetValue("redaction_count", out var value) &&
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count))
{
return count;
}
return 0;
}
private static AdvisoryChatServiceResult CreateMissingContextResult(
Models.AdvisoryChatIntent intent, string? artifactDigest, string? findingId)
{
@@ -706,13 +859,37 @@ public interface IAdvisoryChatAuditLogger
Task LogSuccessAsync(
AdvisoryChatRequest request,
IntentRoutingResult routing,
string sanitizedPrompt,
Models.AdvisoryChatEvidenceBundle? evidenceBundle,
Models.AdvisoryChatResponse response,
AdvisoryChatDiagnostics diagnostics,
ChatToolPolicyResult toolPolicy,
ChatQuotaStatus? quotaStatus,
CancellationToken cancellationToken);
Task LogBlockedAsync(
AdvisoryChatRequest request,
IntentRoutingResult routing,
AdvisoryGuardrailResult guardrailResult,
string sanitizedPrompt,
Models.AdvisoryChatEvidenceBundle? evidenceBundle,
ChatToolPolicyResult toolPolicy,
ChatQuotaStatus? quotaStatus,
CancellationToken cancellationToken);
Task LogQuotaDeniedAsync(
AdvisoryChatRequest request,
IntentRoutingResult routing,
AdvisoryRedactionResult promptRedaction,
ChatQuotaDecision decision,
ChatToolPolicyResult toolPolicy,
CancellationToken cancellationToken);
Task LogToolAccessDeniedAsync(
AdvisoryChatRequest request,
IntentRoutingResult routing,
AdvisoryRedactionResult promptRedaction,
ChatToolPolicyResult toolPolicy,
string reason,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,31 @@
// <copyright file="LocalChatInferenceClient.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.AdvisoryAI.Chat.Services;
/// <summary>
/// Local prompt-based inference client for offline/dev usage.
/// </summary>
internal sealed class LocalChatInferenceClient : IAdvisoryInferenceClient
{
private const int MaxCompletionChars = 4000;
public Task<AdvisoryInferenceResult> CompleteAsync(
string prompt,
AdvisoryInferenceOptions options,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(prompt);
var completion = prompt.Length > MaxCompletionChars
? prompt[..MaxCompletionChars]
: prompt;
return Task.FromResult(new AdvisoryInferenceResult
{
Completion = completion,
PromptTokens = 0,
CompletionTokens = 0
});
}
}

View File

@@ -0,0 +1,56 @@
// <copyright file="NullAdvisoryChatAuditLogger.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using StellaOps.AdvisoryAI.Chat.Routing;
using StellaOps.AdvisoryAI.Chat.Settings;
using StellaOps.AdvisoryAI.Guardrails;
using Models = StellaOps.AdvisoryAI.Chat.Models;
namespace StellaOps.AdvisoryAI.Chat.Services;
/// <summary>
/// No-op audit logger for chat interactions.
/// </summary>
internal sealed class NullAdvisoryChatAuditLogger : IAdvisoryChatAuditLogger
{
public Task LogSuccessAsync(
AdvisoryChatRequest request,
IntentRoutingResult routing,
string sanitizedPrompt,
Models.AdvisoryChatEvidenceBundle? evidenceBundle,
Models.AdvisoryChatResponse response,
AdvisoryChatDiagnostics diagnostics,
ChatToolPolicyResult toolPolicy,
ChatQuotaStatus? quotaStatus,
CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task LogBlockedAsync(
AdvisoryChatRequest request,
IntentRoutingResult routing,
AdvisoryGuardrailResult guardrailResult,
string sanitizedPrompt,
Models.AdvisoryChatEvidenceBundle? evidenceBundle,
ChatToolPolicyResult toolPolicy,
ChatQuotaStatus? quotaStatus,
CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task LogQuotaDeniedAsync(
AdvisoryChatRequest request,
IntentRoutingResult routing,
AdvisoryRedactionResult promptRedaction,
ChatQuotaDecision decision,
ChatToolPolicyResult toolPolicy,
CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task LogToolAccessDeniedAsync(
AdvisoryChatRequest request,
IntentRoutingResult routing,
AdvisoryRedactionResult promptRedaction,
ChatToolPolicyResult toolPolicy,
string reason,
CancellationToken cancellationToken)
=> Task.CompletedTask;
}

View File

@@ -0,0 +1,530 @@
// <copyright file="PostgresAdvisoryChatAuditLogger.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using NpgsqlTypes;
using StellaOps.AdvisoryAI.Chat.Audit;
using StellaOps.AdvisoryAI.Chat.Models;
using StellaOps.AdvisoryAI.Chat.Options;
using StellaOps.AdvisoryAI.Chat.Routing;
using StellaOps.AdvisoryAI.Chat.Settings;
using StellaOps.AdvisoryAI.Guardrails;
namespace StellaOps.AdvisoryAI.Chat.Services;
internal sealed class PostgresAdvisoryChatAuditLogger : IAdvisoryChatAuditLogger, IAsyncDisposable
{
private const string DefaultSchema = "advisoryai";
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<PostgresAdvisoryChatAuditLogger> _logger;
private readonly TimeProvider _timeProvider;
private readonly bool _includeEvidenceBundle;
private readonly string _schema;
private readonly string _insertSessionSql;
private readonly string _insertMessageSql;
private readonly string _insertDecisionSql;
private readonly string _insertToolSql;
private readonly string _insertLinkSql;
public PostgresAdvisoryChatAuditLogger(
IOptions<AdvisoryChatOptions> options,
TimeProvider timeProvider,
ILogger<PostgresAdvisoryChatAuditLogger> logger)
{
ArgumentNullException.ThrowIfNull(options);
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var settings = options.Value ?? new AdvisoryChatOptions();
var audit = settings.Audit ?? new AuditOptions();
if (string.IsNullOrWhiteSpace(audit.ConnectionString))
{
throw new InvalidOperationException("Advisory chat audit connection string is required.");
}
_includeEvidenceBundle = audit.IncludeEvidenceBundle;
_schema = NormalizeSchemaName(audit.SchemaName);
_dataSource = new NpgsqlDataSourceBuilder(audit.ConnectionString).Build();
_insertSessionSql = $"""
INSERT INTO {_schema}.chat_sessions (
session_id,
tenant_id,
user_id,
conversation_id,
correlation_id,
intent,
decision,
decision_code,
decision_reason,
model_id,
model_hash,
prompt_hash,
response_hash,
response_id,
bundle_id,
redactions_applied,
prompt_tokens,
completion_tokens,
total_tokens,
latency_ms,
evidence_bundle_json,
created_at
) VALUES (
@session_id,
@tenant_id,
@user_id,
@conversation_id,
@correlation_id,
@intent,
@decision,
@decision_code,
@decision_reason,
@model_id,
@model_hash,
@prompt_hash,
@response_hash,
@response_id,
@bundle_id,
@redactions_applied,
@prompt_tokens,
@completion_tokens,
@total_tokens,
@latency_ms,
@evidence_bundle_json,
@created_at
)
ON CONFLICT (session_id) DO NOTHING
""";
_insertMessageSql = $"""
INSERT INTO {_schema}.chat_messages (
message_id,
session_id,
role,
content,
content_hash,
redaction_count,
created_at
) VALUES (
@message_id,
@session_id,
@role,
@content,
@content_hash,
@redaction_count,
@created_at
)
ON CONFLICT (message_id) DO NOTHING
""";
_insertDecisionSql = $"""
INSERT INTO {_schema}.chat_policy_decisions (
decision_id,
session_id,
policy_type,
decision,
reason,
payload_json,
created_at
) VALUES (
@decision_id,
@session_id,
@policy_type,
@decision,
@reason,
@payload_json,
@created_at
)
ON CONFLICT (decision_id) DO NOTHING
""";
_insertToolSql = $"""
INSERT INTO {_schema}.chat_tool_invocations (
invocation_id,
session_id,
tool_name,
input_hash,
output_hash,
payload_json,
invoked_at
) VALUES (
@invocation_id,
@session_id,
@tool_name,
@input_hash,
@output_hash,
@payload_json,
@invoked_at
)
ON CONFLICT (invocation_id) DO NOTHING
""";
_insertLinkSql = $"""
INSERT INTO {_schema}.chat_evidence_links (
link_id,
session_id,
link_type,
link,
description,
confidence,
link_hash,
created_at
) VALUES (
@link_id,
@session_id,
@link_type,
@link,
@description,
@confidence,
@link_hash,
@created_at
)
ON CONFLICT (link_id) DO NOTHING
""";
}
public async Task LogSuccessAsync(
AdvisoryChatRequest request,
IntentRoutingResult routing,
string sanitizedPrompt,
AdvisoryChatEvidenceBundle? evidenceBundle,
AdvisoryChatResponse response,
AdvisoryChatDiagnostics diagnostics,
ChatToolPolicyResult toolPolicy,
ChatQuotaStatus? quotaStatus,
CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
var envelope = AdvisoryChatAuditEnvelopeBuilder.BuildSuccess(
request,
routing,
sanitizedPrompt,
evidenceBundle,
response,
diagnostics,
quotaStatus,
toolPolicy,
now,
_includeEvidenceBundle);
await PersistEnvelopeAsync(envelope, cancellationToken).ConfigureAwait(false);
}
public async Task LogBlockedAsync(
AdvisoryChatRequest request,
IntentRoutingResult routing,
AdvisoryGuardrailResult guardrailResult,
string sanitizedPrompt,
AdvisoryChatEvidenceBundle? evidenceBundle,
ChatToolPolicyResult toolPolicy,
ChatQuotaStatus? quotaStatus,
CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
var envelope = AdvisoryChatAuditEnvelopeBuilder.BuildGuardrailBlocked(
request,
routing,
sanitizedPrompt,
guardrailResult,
evidenceBundle,
toolPolicy,
quotaStatus,
now,
_includeEvidenceBundle);
await PersistEnvelopeAsync(envelope, cancellationToken).ConfigureAwait(false);
}
public async Task LogQuotaDeniedAsync(
AdvisoryChatRequest request,
IntentRoutingResult routing,
AdvisoryRedactionResult promptRedaction,
ChatQuotaDecision decision,
ChatToolPolicyResult toolPolicy,
CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
var envelope = AdvisoryChatAuditEnvelopeBuilder.BuildQuotaDenied(
request,
routing,
promptRedaction,
decision,
toolPolicy,
now);
await PersistEnvelopeAsync(envelope, cancellationToken).ConfigureAwait(false);
}
public async Task LogToolAccessDeniedAsync(
AdvisoryChatRequest request,
IntentRoutingResult routing,
AdvisoryRedactionResult promptRedaction,
ChatToolPolicyResult toolPolicy,
string reason,
CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
var envelope = AdvisoryChatAuditEnvelopeBuilder.BuildToolAccessDenied(
request,
routing,
promptRedaction,
toolPolicy,
reason,
now);
await PersistEnvelopeAsync(envelope, cancellationToken).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
}
private async Task PersistEnvelopeAsync(
ChatAuditEnvelope envelope,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(envelope);
try
{
await using var connection = await _dataSource
.OpenConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var transaction = await connection
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await InsertSessionAsync(connection, transaction, envelope.Session, cancellationToken)
.ConfigureAwait(false);
if (!envelope.Messages.IsDefaultOrEmpty)
{
foreach (var message in envelope.Messages)
{
await InsertMessageAsync(connection, transaction, message, cancellationToken)
.ConfigureAwait(false);
}
}
if (!envelope.PolicyDecisions.IsDefaultOrEmpty)
{
foreach (var decision in envelope.PolicyDecisions)
{
await InsertDecisionAsync(connection, transaction, decision, cancellationToken)
.ConfigureAwait(false);
}
}
if (!envelope.ToolInvocations.IsDefaultOrEmpty)
{
foreach (var invocation in envelope.ToolInvocations)
{
await InsertToolInvocationAsync(connection, transaction, invocation, cancellationToken)
.ConfigureAwait(false);
}
}
if (!envelope.EvidenceLinks.IsDefaultOrEmpty)
{
foreach (var link in envelope.EvidenceLinks)
{
await InsertEvidenceLinkAsync(connection, transaction, link, cancellationToken)
.ConfigureAwait(false);
}
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist advisory chat audit log");
}
}
private async Task InsertSessionAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
ChatAuditSession session,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = _insertSessionSql;
command.Transaction = transaction;
AddParameter(command, "session_id", session.SessionId);
AddParameter(command, "tenant_id", session.TenantId);
AddParameter(command, "user_id", session.UserId);
AddParameter(command, "conversation_id", session.ConversationId);
AddParameter(command, "correlation_id", session.CorrelationId);
AddParameter(command, "intent", session.Intent);
AddParameter(command, "decision", session.Decision);
AddParameter(command, "decision_code", session.DecisionCode);
AddParameter(command, "decision_reason", session.DecisionReason);
AddParameter(command, "model_id", session.ModelId);
AddParameter(command, "model_hash", session.ModelHash);
AddParameter(command, "prompt_hash", session.PromptHash);
AddParameter(command, "response_hash", session.ResponseHash);
AddParameter(command, "response_id", session.ResponseId);
AddParameter(command, "bundle_id", session.BundleId);
AddParameter(command, "redactions_applied", session.RedactionsApplied);
AddParameter(command, "prompt_tokens", session.PromptTokens);
AddParameter(command, "completion_tokens", session.CompletionTokens);
AddParameter(command, "total_tokens", session.TotalTokens);
AddParameter(command, "latency_ms", session.LatencyMs);
AddParameter(command, "evidence_bundle_json", session.EvidenceBundleJson, NpgsqlDbType.Jsonb);
AddParameter(command, "created_at", session.CreatedAt);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task InsertMessageAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
ChatAuditMessage message,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = _insertMessageSql;
command.Transaction = transaction;
AddParameter(command, "message_id", message.MessageId);
AddParameter(command, "session_id", message.SessionId);
AddParameter(command, "role", message.Role);
AddParameter(command, "content", message.Content);
AddParameter(command, "content_hash", message.ContentHash);
AddParameter(command, "redaction_count", message.RedactionCount);
AddParameter(command, "created_at", message.CreatedAt);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task InsertDecisionAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
ChatAuditPolicyDecision decision,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = _insertDecisionSql;
command.Transaction = transaction;
AddParameter(command, "decision_id", decision.DecisionId);
AddParameter(command, "session_id", decision.SessionId);
AddParameter(command, "policy_type", decision.PolicyType);
AddParameter(command, "decision", decision.Decision);
AddParameter(command, "reason", decision.Reason);
AddParameter(command, "payload_json", decision.PayloadJson, NpgsqlDbType.Jsonb);
AddParameter(command, "created_at", decision.CreatedAt);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task InsertToolInvocationAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
ChatAuditToolInvocation invocation,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = _insertToolSql;
command.Transaction = transaction;
AddParameter(command, "invocation_id", invocation.InvocationId);
AddParameter(command, "session_id", invocation.SessionId);
AddParameter(command, "tool_name", invocation.ToolName);
AddParameter(command, "input_hash", invocation.InputHash);
AddParameter(command, "output_hash", invocation.OutputHash);
AddParameter(command, "payload_json", invocation.PayloadJson, NpgsqlDbType.Jsonb);
AddParameter(command, "invoked_at", invocation.InvokedAt);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task InsertEvidenceLinkAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
ChatAuditEvidenceLink link,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = _insertLinkSql;
command.Transaction = transaction;
AddParameter(command, "link_id", link.LinkId);
AddParameter(command, "session_id", link.SessionId);
AddParameter(command, "link_type", link.LinkType);
AddParameter(command, "link", link.Link);
AddParameter(command, "description", link.Description);
AddParameter(command, "confidence", link.Confidence);
AddParameter(command, "link_hash", link.LinkHash);
AddParameter(command, "created_at", link.CreatedAt);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static void AddParameter(
NpgsqlCommand command,
string name,
object? value,
NpgsqlDbType? type = null)
{
ArgumentNullException.ThrowIfNull(command);
if (type.HasValue)
{
command.Parameters.Add(new NpgsqlParameter(name, type.Value)
{
Value = value ?? DBNull.Value
});
return;
}
command.Parameters.AddWithValue(name, value ?? DBNull.Value);
}
private static string NormalizeSchemaName(string? schemaName)
{
if (string.IsNullOrWhiteSpace(schemaName))
{
return DefaultSchema;
}
var trimmed = schemaName.Trim();
if (!IsValidSchemaName(trimmed))
{
return DefaultSchema;
}
return trimmed.ToLowerInvariant();
}
private static bool IsValidSchemaName(string schemaName)
{
if (string.IsNullOrWhiteSpace(schemaName))
{
return false;
}
for (var i = 0; i < schemaName.Length; i++)
{
var ch = schemaName[i];
var isFirst = i == 0;
if (isFirst)
{
if (!(char.IsLetter(ch) || ch == '_'))
{
return false;
}
}
else if (!(char.IsLetterOrDigit(ch) || ch == '_'))
{
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,64 @@
// <copyright file="AdvisoryChatSettingsModels.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Chat.Settings;
/// <summary>
/// Effective chat settings after defaults and overrides are merged.
/// </summary>
public sealed record AdvisoryChatSettings
{
public required ChatQuotaSettings Quotas { get; init; }
public required ChatToolAccessSettings Tools { get; init; }
}
/// <summary>
/// Quota settings with concrete values.
/// </summary>
public sealed record ChatQuotaSettings
{
public int RequestsPerMinute { get; init; }
public int RequestsPerDay { get; init; }
public int TokensPerDay { get; init; }
public int ToolCallsPerDay { get; init; }
}
/// <summary>
/// Tool access settings with concrete values.
/// </summary>
public sealed record ChatToolAccessSettings
{
public bool AllowAll { get; init; }
public ImmutableArray<string> AllowedTools { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// Chat settings overrides stored per tenant or per user.
/// </summary>
public sealed record AdvisoryChatSettingsOverrides
{
public ChatQuotaOverrides Quotas { get; init; } = new();
public ChatToolAccessOverrides Tools { get; init; } = new();
}
/// <summary>
/// Quota overrides (null means use default).
/// </summary>
public sealed record ChatQuotaOverrides
{
public int? RequestsPerMinute { get; init; }
public int? RequestsPerDay { get; init; }
public int? TokensPerDay { get; init; }
public int? ToolCallsPerDay { get; init; }
}
/// <summary>
/// Tool access overrides (null means use default).
/// </summary>
public sealed record ChatToolAccessOverrides
{
public bool? AllowAll { get; init; }
public ImmutableArray<string>? AllowedTools { get; init; }
}

View File

@@ -0,0 +1,203 @@
// <copyright file="AdvisoryChatSettingsService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Chat.Options;
namespace StellaOps.AdvisoryAI.Chat.Settings;
/// <summary>
/// Provides merged chat settings (env defaults + tenant/user overrides).
/// </summary>
public interface IAdvisoryChatSettingsService
{
Task<AdvisoryChatSettings> GetEffectiveSettingsAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default);
Task SetTenantOverridesAsync(
string tenantId,
AdvisoryChatSettingsOverrides overrides,
CancellationToken cancellationToken = default);
Task SetUserOverridesAsync(
string tenantId,
string userId,
AdvisoryChatSettingsOverrides overrides,
CancellationToken cancellationToken = default);
Task<bool> ClearTenantOverridesAsync(
string tenantId,
CancellationToken cancellationToken = default);
Task<bool> ClearUserOverridesAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of chat settings service.
/// </summary>
public sealed class AdvisoryChatSettingsService : IAdvisoryChatSettingsService
{
private readonly IAdvisoryChatSettingsStore _store;
private readonly AdvisoryChatOptions _defaults;
public AdvisoryChatSettingsService(
IAdvisoryChatSettingsStore store,
IOptions<AdvisoryChatOptions> options)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_defaults = options?.Value ?? new AdvisoryChatOptions();
}
public async Task<AdvisoryChatSettings> GetEffectiveSettingsAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
var effective = BuildDefaults(_defaults);
var tenantOverrides = await _store.GetTenantOverridesAsync(tenantId, cancellationToken)
.ConfigureAwait(false);
if (tenantOverrides is not null)
{
effective = ApplyOverrides(effective, NormalizeOverrides(tenantOverrides));
}
var userOverrides = await _store.GetUserOverridesAsync(tenantId, userId, cancellationToken)
.ConfigureAwait(false);
if (userOverrides is not null)
{
effective = ApplyOverrides(effective, NormalizeOverrides(userOverrides));
}
return effective;
}
public Task SetTenantOverridesAsync(
string tenantId,
AdvisoryChatSettingsOverrides overrides,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(overrides);
return _store.SetTenantOverridesAsync(tenantId, NormalizeOverrides(overrides), cancellationToken);
}
public Task SetUserOverridesAsync(
string tenantId,
string userId,
AdvisoryChatSettingsOverrides overrides,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
ArgumentNullException.ThrowIfNull(overrides);
return _store.SetUserOverridesAsync(tenantId, userId, NormalizeOverrides(overrides), cancellationToken);
}
public Task<bool> ClearTenantOverridesAsync(
string tenantId,
CancellationToken cancellationToken = default)
=> _store.ClearTenantOverridesAsync(tenantId, cancellationToken);
public Task<bool> ClearUserOverridesAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default)
=> _store.ClearUserOverridesAsync(tenantId, userId, cancellationToken);
private static AdvisoryChatSettings BuildDefaults(AdvisoryChatOptions defaults)
{
var toolOptions = defaults.Tools ?? new ToolAccessOptions();
var tools = toolOptions.AllowedTools ?? new List<string>();
var normalizedTools = NormalizeToolList(tools.ToImmutableArray());
return new AdvisoryChatSettings
{
Quotas = new ChatQuotaSettings
{
RequestsPerMinute = defaults.Quotas.RequestsPerMinute,
RequestsPerDay = defaults.Quotas.RequestsPerDay,
TokensPerDay = defaults.Quotas.TokensPerDay,
ToolCallsPerDay = defaults.Quotas.ToolCallsPerDay
},
Tools = new ChatToolAccessSettings
{
AllowAll = toolOptions.AllowAll,
AllowedTools = normalizedTools
}
};
}
private static AdvisoryChatSettings ApplyOverrides(
AdvisoryChatSettings defaults,
AdvisoryChatSettingsOverrides overrides)
{
var quotaOverrides = overrides.Quotas ?? new ChatQuotaOverrides();
var toolOverrides = overrides.Tools ?? new ChatToolAccessOverrides();
var quotas = new ChatQuotaSettings
{
RequestsPerMinute = quotaOverrides.RequestsPerMinute ?? defaults.Quotas.RequestsPerMinute,
RequestsPerDay = quotaOverrides.RequestsPerDay ?? defaults.Quotas.RequestsPerDay,
TokensPerDay = quotaOverrides.TokensPerDay ?? defaults.Quotas.TokensPerDay,
ToolCallsPerDay = quotaOverrides.ToolCallsPerDay ?? defaults.Quotas.ToolCallsPerDay
};
var allowedTools = toolOverrides.AllowedTools ?? defaults.Tools.AllowedTools;
var normalizedTools = NormalizeToolList(allowedTools);
var tools = new ChatToolAccessSettings
{
AllowAll = toolOverrides.AllowAll ?? defaults.Tools.AllowAll,
AllowedTools = normalizedTools
};
return new AdvisoryChatSettings
{
Quotas = quotas,
Tools = tools
};
}
private static AdvisoryChatSettingsOverrides NormalizeOverrides(AdvisoryChatSettingsOverrides overrides)
{
var tools = overrides.Tools?.AllowedTools;
ImmutableArray<string>? normalizedTools = tools is null
? null
: NormalizeToolList(tools.Value);
return overrides with
{
Tools = overrides.Tools is null
? new ChatToolAccessOverrides()
: overrides.Tools with { AllowedTools = normalizedTools }
};
}
private static ImmutableArray<string> NormalizeToolList(ImmutableArray<string> tools)
{
if (tools.IsDefaultOrEmpty)
{
return ImmutableArray<string>.Empty;
}
var normalized = tools
.Where(tool => !string.IsNullOrWhiteSpace(tool))
.Select(tool => tool.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(tool => tool, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return normalized;
}
}

View File

@@ -0,0 +1,140 @@
// <copyright file="AdvisoryChatSettingsStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.AdvisoryAI.Chat.Settings;
/// <summary>
/// Storage for chat settings overrides.
/// </summary>
public interface IAdvisoryChatSettingsStore
{
Task<AdvisoryChatSettingsOverrides?> GetTenantOverridesAsync(
string tenantId,
CancellationToken cancellationToken = default);
Task<AdvisoryChatSettingsOverrides?> GetUserOverridesAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default);
Task SetTenantOverridesAsync(
string tenantId,
AdvisoryChatSettingsOverrides overrides,
CancellationToken cancellationToken = default);
Task SetUserOverridesAsync(
string tenantId,
string userId,
AdvisoryChatSettingsOverrides overrides,
CancellationToken cancellationToken = default);
Task<bool> ClearTenantOverridesAsync(
string tenantId,
CancellationToken cancellationToken = default);
Task<bool> ClearUserOverridesAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory chat settings store for development/testing.
/// </summary>
public sealed class InMemoryAdvisoryChatSettingsStore : IAdvisoryChatSettingsStore
{
private readonly Dictionary<string, AdvisoryChatSettingsOverrides> _tenantOverrides = new();
private readonly Dictionary<string, AdvisoryChatSettingsOverrides> _userOverrides = new();
private readonly object _lock = new();
public Task<AdvisoryChatSettingsOverrides?> GetTenantOverridesAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
lock (_lock)
{
return Task.FromResult(
_tenantOverrides.TryGetValue(tenantId, out var existing)
? existing
: null);
}
}
public Task<AdvisoryChatSettingsOverrides?> GetUserOverridesAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
var key = MakeUserKey(tenantId, userId);
lock (_lock)
{
return Task.FromResult(
_userOverrides.TryGetValue(key, out var existing)
? existing
: null);
}
}
public Task SetTenantOverridesAsync(
string tenantId,
AdvisoryChatSettingsOverrides overrides,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(overrides);
lock (_lock)
{
_tenantOverrides[tenantId] = overrides;
}
return Task.CompletedTask;
}
public Task SetUserOverridesAsync(
string tenantId,
string userId,
AdvisoryChatSettingsOverrides overrides,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
ArgumentNullException.ThrowIfNull(overrides);
var key = MakeUserKey(tenantId, userId);
lock (_lock)
{
_userOverrides[key] = overrides;
}
return Task.CompletedTask;
}
public Task<bool> ClearTenantOverridesAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
lock (_lock)
{
return Task.FromResult(_tenantOverrides.Remove(tenantId));
}
}
public Task<bool> ClearUserOverridesAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
var key = MakeUserKey(tenantId, userId);
lock (_lock)
{
return Task.FromResult(_userOverrides.Remove(key));
}
}
private static string MakeUserKey(string tenantId, string userId) => $"{tenantId}:{userId}";
}

View File

@@ -0,0 +1,234 @@
// <copyright file="AdvisoryChatToolPolicy.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Linq;
using StellaOps.AdvisoryAI.Chat.Options;
namespace StellaOps.AdvisoryAI.Chat.Settings;
/// <summary>
/// Tool policy resolution result for the chat gateway.
/// </summary>
public sealed record ChatToolPolicyResult
{
public bool AllowAll { get; init; }
public bool AllowSbom { get; init; }
public bool AllowVex { get; init; }
public bool AllowReachability { get; init; }
public bool AllowBinaryPatch { get; init; }
public bool AllowOpsMemory { get; init; }
public bool AllowPolicy { get; init; }
public bool AllowProvenance { get; init; }
public bool AllowFix { get; init; }
public bool AllowContext { get; init; }
public int ToolCallCount { get; init; }
public ImmutableArray<string> AllowedTools { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// Resolves tool policy from settings and provider defaults.
/// </summary>
public static class AdvisoryChatToolPolicy
{
private static readonly ImmutableArray<string> SbomTools =
[
"sbom.read",
"scanner.findings.topk"
];
private static readonly ImmutableArray<string> VexTools =
[
"vex.query"
];
private static readonly ImmutableArray<string> ReachabilityTools =
[
"reachability.graph.query",
"reachability.why"
];
private static readonly ImmutableArray<string> BinaryPatchTools =
[
"binary.patch.detect"
];
private static readonly ImmutableArray<string> OpsMemoryTools =
[
"opsmemory.read"
];
private static readonly ImmutableArray<string> PolicyTools =
[
"policy.eval"
];
private static readonly ImmutableArray<string> ProvenanceTools =
[
"provenance.read"
];
private static readonly ImmutableArray<string> FixTools =
[
"fix.suggest"
];
private static readonly ImmutableArray<string> ContextTools =
[
"context.read"
];
public static ChatToolPolicyResult Resolve(
ChatToolAccessSettings tools,
DataProviderOptions providers,
bool includeReachability,
bool includeBinaryPatch,
bool includeOpsMemory)
{
ArgumentNullException.ThrowIfNull(tools);
ArgumentNullException.ThrowIfNull(providers);
var allowAll = tools.AllowAll;
var allowedTools = allowAll
? BuildCanonicalAllowedTools(providers)
: tools.AllowedTools;
var allowSet = allowAll
? null
: allowedTools.ToHashSet(StringComparer.OrdinalIgnoreCase);
var allowSbom = providers.SbomEnabled && (allowAll || ContainsAny(allowSet, SbomTools));
var allowVex = providers.VexEnabled && (allowAll || ContainsAny(allowSet, VexTools));
var allowReachability = providers.ReachabilityEnabled && (allowAll || ContainsAny(allowSet, ReachabilityTools));
var allowBinaryPatch = providers.BinaryPatchEnabled && (allowAll || ContainsAny(allowSet, BinaryPatchTools));
var allowOpsMemory = providers.OpsMemoryEnabled && (allowAll || ContainsAny(allowSet, OpsMemoryTools));
var allowPolicy = providers.PolicyEnabled && (allowAll || ContainsAny(allowSet, PolicyTools));
var allowProvenance = providers.ProvenanceEnabled && (allowAll || ContainsAny(allowSet, ProvenanceTools));
var allowFix = providers.FixEnabled && (allowAll || ContainsAny(allowSet, FixTools));
var allowContext = providers.ContextEnabled && (allowAll || ContainsAny(allowSet, ContextTools));
var toolCalls = 0;
if (allowSbom)
{
toolCalls++;
}
if (allowVex)
{
toolCalls++;
}
if (allowPolicy)
{
toolCalls++;
}
if (allowProvenance)
{
toolCalls++;
}
if (allowFix)
{
toolCalls++;
}
if (allowContext)
{
toolCalls++;
}
if (includeReachability && allowReachability)
{
toolCalls++;
}
if (includeBinaryPatch && allowBinaryPatch)
{
toolCalls++;
}
if (includeOpsMemory && allowOpsMemory)
{
toolCalls++;
}
return new ChatToolPolicyResult
{
AllowAll = allowAll,
AllowSbom = allowSbom,
AllowVex = allowVex,
AllowReachability = allowReachability,
AllowBinaryPatch = allowBinaryPatch,
AllowOpsMemory = allowOpsMemory,
AllowPolicy = allowPolicy,
AllowProvenance = allowProvenance,
AllowFix = allowFix,
AllowContext = allowContext,
ToolCallCount = toolCalls,
AllowedTools = allowedTools
};
}
private static ImmutableArray<string> BuildCanonicalAllowedTools(DataProviderOptions providers)
{
var builder = ImmutableArray.CreateBuilder<string>();
if (providers.SbomEnabled)
{
builder.AddRange(SbomTools);
}
if (providers.VexEnabled)
{
builder.AddRange(VexTools);
}
if (providers.ReachabilityEnabled)
{
builder.AddRange(ReachabilityTools);
}
if (providers.BinaryPatchEnabled)
{
builder.AddRange(BinaryPatchTools);
}
if (providers.OpsMemoryEnabled)
{
builder.AddRange(OpsMemoryTools);
}
if (providers.PolicyEnabled)
{
builder.AddRange(PolicyTools);
}
if (providers.ProvenanceEnabled)
{
builder.AddRange(ProvenanceTools);
}
if (providers.FixEnabled)
{
builder.AddRange(FixTools);
}
if (providers.ContextEnabled)
{
builder.AddRange(ContextTools);
}
return builder.ToImmutable();
}
private static bool ContainsAny(HashSet<string>? allowSet, ImmutableArray<string> candidates)
{
if (allowSet is null)
{
return false;
}
foreach (var candidate in candidates)
{
if (allowSet.Contains(candidate))
{
return true;
}
}
return false;
}
}

View File

@@ -8,12 +8,15 @@ using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Queue;
using StellaOps.AdvisoryAI.Tools;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Chunking;
using StellaOps.AdvisoryAI.Providers;
using StellaOps.AdvisoryAI.Retrievers;
using StellaOps.AdvisoryAI.Execution;
using StellaOps.AdvisoryAI.Guardrails;
using StellaOps.AdvisoryAI.Outputs;
using StellaOps.AdvisoryAI.Prompting;
using StellaOps.AdvisoryAI.Vectorization;
using StellaOps.Cryptography;
namespace StellaOps.AdvisoryAI.DependencyInjection;
@@ -31,6 +34,15 @@ public static class ToolsetServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
services.AddAdvisoryDeterministicToolset();
services.TryAddSingleton<IAdvisoryDocumentProvider, NullAdvisoryDocumentProvider>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDocumentChunker, CsafDocumentChunker>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDocumentChunker, OsvDocumentChunker>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDocumentChunker, MarkdownDocumentChunker>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDocumentChunker, OpenVexDocumentChunker>());
services.TryAddSingleton<IAdvisoryStructuredRetriever, AdvisoryStructuredRetriever>();
services.TryAddSingleton<ICryptoHash, DefaultCryptoHash>();
services.TryAddSingleton<IVectorEncoder, DeterministicHashVectorEncoder>();
services.TryAddSingleton<IAdvisoryVectorRetriever, AdvisoryVectorRetriever>();
services.TryAddSingleton<ISbomContextClient, NullSbomContextClient>();
services.TryAddSingleton<ISbomContextRetriever, SbomContextRetriever>();

View File

@@ -0,0 +1,51 @@
// <copyright file="NullEvidencePackSigner.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text;
using StellaOps.Evidence.Pack;
using StellaOps.Evidence.Pack.Models;
namespace StellaOps.AdvisoryAI.Evidence;
/// <summary>
/// No-op DSSE signer for evidence packs when signing is not configured.
/// </summary>
public sealed class NullEvidencePackSigner : IEvidencePackSigner
{
private const string KeyId = "unsigned";
private readonly TimeProvider _timeProvider;
public NullEvidencePackSigner(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<DsseEnvelope> SignAsync(EvidencePack pack, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(pack);
var digest = pack.ComputeContentDigest();
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(digest));
return Task.FromResult(new DsseEnvelope
{
PayloadType = "application/vnd.stellaops.evidence-pack+json",
Payload = payload,
PayloadDigest = digest,
Signatures = ImmutableArray.Create(new DsseSignature
{
KeyId = KeyId,
Sig = string.Empty
})
});
}
public Task<SignatureVerificationResult> VerifyAsync(
DsseEnvelope envelope,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(envelope);
return Task.FromResult(SignatureVerificationResult.Success(KeyId, _timeProvider.GetUtcNow()));
}
}

View File

@@ -101,6 +101,10 @@ public sealed class EvidenceAnchoredExplanationGenerator : IExplanationGenerator
// 9. Store for replay
await _store.StoreAsync(result, cancellationToken);
if (_store is IExplanationRequestStore requestStore)
{
await requestStore.StoreRequestAsync(explanationId, request, cancellationToken);
}
return result;
}

View File

@@ -0,0 +1,15 @@
// <copyright file="IExplanationRequestStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.AdvisoryAI.Explanation;
/// <summary>
/// Optional store for persisting explanation requests for replay.
/// </summary>
public interface IExplanationRequestStore
{
Task StoreRequestAsync(
string explanationId,
ExplanationRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,44 @@
// <copyright file="InMemoryExplanationStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
namespace StellaOps.AdvisoryAI.Explanation;
public sealed class InMemoryExplanationStore : IExplanationStore, IExplanationRequestStore
{
private readonly ConcurrentDictionary<string, ExplanationResult> _results = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ExplanationRequest> _requests = new(StringComparer.Ordinal);
public Task StoreAsync(ExplanationResult result, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
_results[result.ExplanationId] = result;
return Task.CompletedTask;
}
public Task StoreRequestAsync(
string explanationId,
ExplanationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(explanationId);
ArgumentNullException.ThrowIfNull(request);
_requests[explanationId] = request;
return Task.CompletedTask;
}
public Task<ExplanationResult?> GetAsync(string explanationId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(explanationId);
_results.TryGetValue(explanationId, out var result);
return Task.FromResult(result);
}
public Task<ExplanationRequest?> GetRequestAsync(string explanationId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(explanationId);
_requests.TryGetValue(explanationId, out var request);
return Task.FromResult(request);
}
}

View File

@@ -0,0 +1,13 @@
// <copyright file="NullCitationExtractor.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.AdvisoryAI.Explanation;
public sealed class NullCitationExtractor : ICitationExtractor
{
public Task<IReadOnlyList<ExplanationCitation>> ExtractCitationsAsync(
string content,
EvidenceContext evidence,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ExplanationCitation>>(Array.Empty<ExplanationCitation>());
}

View File

@@ -0,0 +1,43 @@
// <copyright file="NullEvidenceRetrievalService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Security.Cryptography;
namespace StellaOps.AdvisoryAI.Explanation;
public sealed class NullEvidenceRetrievalService : IEvidenceRetrievalService
{
private static readonly EvidenceContext EmptyContext = new()
{
SbomEvidence = Array.Empty<EvidenceNode>(),
ReachabilityEvidence = Array.Empty<EvidenceNode>(),
RuntimeEvidence = Array.Empty<EvidenceNode>(),
VexEvidence = Array.Empty<EvidenceNode>(),
PatchEvidence = Array.Empty<EvidenceNode>(),
ContextHash = ComputeEmptyContextHash()
};
public Task<EvidenceContext> RetrieveEvidenceAsync(
string findingId,
string artifactDigest,
string vulnerabilityId,
string? componentPurl = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(EmptyContext);
public Task<EvidenceNode?> GetEvidenceNodeAsync(
string evidenceId,
CancellationToken cancellationToken = default)
=> Task.FromResult<EvidenceNode?>(null);
public Task<bool> ValidateEvidenceAsync(
IEnumerable<string> evidenceIds,
CancellationToken cancellationToken = default)
=> Task.FromResult(true);
private static string ComputeEmptyContextHash()
{
var bytes = SHA256.HashData(Array.Empty<byte>());
return Convert.ToHexStringLower(bytes);
}
}

View File

@@ -0,0 +1,33 @@
// <copyright file="NullExplanationInferenceClient.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.AdvisoryAI.Explanation;
public sealed class NullExplanationInferenceClient : IExplanationInferenceClient
{
public Task<ExplanationInferenceResult> GenerateAsync(
ExplanationPrompt prompt,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(prompt);
var promptHash = ComputeHash(prompt.Content ?? string.Empty);
var content = $"Placeholder explanation (no model). prompt_hash=sha256:{promptHash}";
return Task.FromResult(new ExplanationInferenceResult
{
Content = content,
Confidence = 0.0,
ModelId = "stub-explainer:v0"
});
}
private static string ComputeHash(string content)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToHexStringLower(bytes);
}
}

View File

@@ -10,6 +10,8 @@ namespace StellaOps.AdvisoryAI.Guardrails;
public interface IAdvisoryGuardrailPipeline
{
Task<AdvisoryGuardrailResult> EvaluateAsync(AdvisoryPrompt prompt, CancellationToken cancellationToken);
AdvisoryRedactionResult Redact(string input);
}
public sealed record AdvisoryGuardrailResult(
@@ -27,6 +29,8 @@ public sealed record AdvisoryGuardrailResult(
public sealed record AdvisoryGuardrailViolation(string Code, string Message);
public sealed record AdvisoryRedactionResult(string Sanitized, int RedactionCount);
public sealed class AdvisoryGuardrailOptions
{
private static readonly string[] DefaultBlockedPhrases =
@@ -38,11 +42,25 @@ public sealed class AdvisoryGuardrailOptions
"please jailbreak"
};
private static readonly string[] DefaultAllowlistPatterns =
{
@"(?i)\bsha256:[0-9a-f]{64}\b",
@"(?i)\bsha1:[0-9a-f]{40}\b",
@"(?i)\bsha384:[0-9a-f]{96}\b",
@"(?i)\bsha512:[0-9a-f]{128}\b"
};
public int MaxPromptLength { get; set; } = 16000;
public bool RequireCitations { get; set; } = true;
public List<string> BlockedPhrases { get; } = new(DefaultBlockedPhrases);
public double EntropyThreshold { get; set; } = 3.5;
public int EntropyMinLength { get; set; } = 20;
public List<string> AllowlistPatterns { get; } = new(DefaultAllowlistPatterns);
}
internal sealed class AdvisoryGuardrailPipeline : IAdvisoryGuardrailPipeline
@@ -51,6 +69,10 @@ internal sealed class AdvisoryGuardrailPipeline : IAdvisoryGuardrailPipeline
private readonly ILogger<AdvisoryGuardrailPipeline>? _logger;
private readonly IReadOnlyList<RedactionRule> _redactionRules;
private readonly string[] _blockedPhraseCache;
private readonly Regex[] _allowlistMatchers;
private readonly double _entropyThreshold;
private readonly int _entropyMinLength;
private readonly Regex? _entropyTokenRegex;
public AdvisoryGuardrailPipeline(
IOptions<AdvisoryGuardrailOptions> options,
@@ -64,19 +86,35 @@ internal sealed class AdvisoryGuardrailPipeline : IAdvisoryGuardrailPipeline
{
new RedactionRule(
new Regex(@"(?i)(aws_secret_access_key\s*[:=]\s*)([A-Za-z0-9\/+=]{40,})", RegexOptions.CultureInvariant | RegexOptions.Compiled),
match => $"{match.Groups[1].Value}[REDACTED_AWS_SECRET]"),
match => $"{match.Groups[1].Value}[REDACTED_AWS_SECRET]",
new[] { "aws_secret_access_key" }),
new RedactionRule(
new Regex(@"(?i)(token|apikey|password)\s*[:=]\s*([A-Za-z0-9\-_/]{16,})", RegexOptions.CultureInvariant | RegexOptions.Compiled),
match => $"{match.Groups[1].Value}: [REDACTED_CREDENTIAL]"),
match => $"{match.Groups[1].Value}: [REDACTED_CREDENTIAL]",
new[] { "token", "apikey", "password" }),
new RedactionRule(
new Regex(@"(?is)-----BEGIN [^-]+ PRIVATE KEY-----.*?-----END [^-]+ PRIVATE KEY-----", RegexOptions.CultureInvariant | RegexOptions.Compiled),
_ => "[REDACTED_PRIVATE_KEY]")
_ => "[REDACTED_PRIVATE_KEY]",
new[] { "private key" })
};
_blockedPhraseCache = _options.BlockedPhrases
.Where(phrase => !string.IsNullOrWhiteSpace(phrase))
.Select(phrase => phrase.Trim().ToLowerInvariant())
.ToArray();
_allowlistMatchers = _options.AllowlistPatterns
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
.Select(pattern => new Regex(pattern, RegexOptions.CultureInvariant | RegexOptions.Compiled))
.ToArray();
_entropyThreshold = _options.EntropyThreshold;
_entropyMinLength = _options.EntropyMinLength;
_entropyTokenRegex = _entropyThreshold > 0 && _entropyMinLength > 0
? new Regex(
$"[A-Za-z0-9+/=_:-]{{{_entropyMinLength},}}",
RegexOptions.CultureInvariant | RegexOptions.Compiled)
: null;
}
public Task<AdvisoryGuardrailResult> EvaluateAsync(AdvisoryPrompt prompt, CancellationToken cancellationToken)
@@ -87,9 +125,10 @@ internal sealed class AdvisoryGuardrailPipeline : IAdvisoryGuardrailPipeline
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
var violations = ImmutableArray.CreateBuilder<AdvisoryGuardrailViolation>();
var redactionCount = ApplyRedactions(ref sanitized);
var redaction = Redact(sanitized);
sanitized = redaction.Sanitized;
metadataBuilder["prompt_length"] = sanitized.Length.ToString(CultureInfo.InvariantCulture);
metadataBuilder["redaction_count"] = redactionCount.ToString(CultureInfo.InvariantCulture);
metadataBuilder["redaction_count"] = redaction.RedactionCount.ToString(CultureInfo.InvariantCulture);
var blocked = false;
@@ -149,12 +188,24 @@ internal sealed class AdvisoryGuardrailPipeline : IAdvisoryGuardrailPipeline
return Task.FromResult(AdvisoryGuardrailResult.Allowed(sanitized, metadata));
}
public AdvisoryRedactionResult Redact(string input)
{
var sanitized = input ?? string.Empty;
var count = ApplyRedactions(ref sanitized);
return new AdvisoryRedactionResult(sanitized, count);
}
private int ApplyRedactions(ref string sanitized)
{
var count = 0;
foreach (var rule in _redactionRules)
{
if (!rule.ShouldApply(sanitized))
{
continue;
}
sanitized = rule.Regex.Replace(sanitized, match =>
{
count++;
@@ -162,10 +213,151 @@ internal sealed class AdvisoryGuardrailPipeline : IAdvisoryGuardrailPipeline
});
}
sanitized = RedactHighEntropy(sanitized, ref count);
return count;
}
private sealed record RedactionRule(Regex Regex, Func<Match, string> Replacement);
private string RedactHighEntropy(string input, ref int count)
{
if (_entropyTokenRegex is null)
{
return input;
}
if (!HasEntropyCandidate(input))
{
return input;
}
var redactions = 0;
var sanitized = _entropyTokenRegex.Replace(input, match =>
{
var token = match.Value;
if (token.Contains("REDACTED", StringComparison.OrdinalIgnoreCase))
{
return token;
}
if (IsAllowlisted(token))
{
return token;
}
var entropy = ComputeShannonEntropy(token);
if (entropy >= _entropyThreshold)
{
redactions++;
return "[REDACTED_HIGH_ENTROPY]";
}
return token;
});
if (redactions > 0)
{
count += redactions;
}
return sanitized;
}
private bool HasEntropyCandidate(string input)
{
if (_entropyMinLength <= 0 || input.Length < _entropyMinLength)
{
return false;
}
var runLength = 0;
foreach (var ch in input)
{
if (IsEntropyCandidateChar(ch))
{
runLength++;
if (runLength >= _entropyMinLength)
{
return true;
}
}
else
{
runLength = 0;
}
}
return false;
}
private static bool IsEntropyCandidateChar(char ch)
=> (ch >= 'A' && ch <= 'Z')
|| (ch >= 'a' && ch <= 'z')
|| (ch >= '0' && ch <= '9')
|| ch is '+' or '/' or '=' or '_' or '-' or ':';
private bool IsAllowlisted(string token)
{
if (_allowlistMatchers.Length == 0)
{
return false;
}
foreach (var matcher in _allowlistMatchers)
{
if (matcher.IsMatch(token))
{
return true;
}
}
return false;
}
private static double ComputeShannonEntropy(string value)
{
if (string.IsNullOrEmpty(value))
{
return 0d;
}
var counts = new Dictionary<char, int>();
foreach (var ch in value)
{
counts.TryGetValue(ch, out var current);
counts[ch] = current + 1;
}
var length = value.Length;
var entropy = 0d;
foreach (var count in counts.Values)
{
var probability = (double)count / length;
entropy -= probability * Math.Log(probability, 2d);
}
return entropy;
}
private sealed record RedactionRule(Regex Regex, Func<Match, string> Replacement, string[]? TriggerTokens)
{
public bool ShouldApply(string input)
{
if (TriggerTokens is null || TriggerTokens.Length == 0)
{
return true;
}
foreach (var token in TriggerTokens)
{
if (input.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
return false;
}
}
}
internal sealed class NoOpAdvisoryGuardrailPipeline : IAdvisoryGuardrailPipeline
@@ -183,4 +375,7 @@ internal sealed class NoOpAdvisoryGuardrailPipeline : IAdvisoryGuardrailPipeline
_logger?.LogDebug("No-op guardrail pipeline invoked for cache key {CacheKey}", prompt.CacheKey);
return Task.FromResult(AdvisoryGuardrailResult.Allowed(prompt.Prompt ?? string.Empty));
}
public AdvisoryRedactionResult Redact(string input)
=> new(input ?? string.Empty, 0);
}

View File

@@ -0,0 +1,25 @@
// <copyright file="InMemoryPolicyIntentStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
namespace StellaOps.AdvisoryAI.PolicyStudio;
public sealed class InMemoryPolicyIntentStore : IPolicyIntentStore
{
private readonly ConcurrentDictionary<string, PolicyIntent> _intents = new(StringComparer.Ordinal);
public Task StoreAsync(PolicyIntent intent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(intent);
_intents[intent.IntentId] = intent;
return Task.CompletedTask;
}
public Task<PolicyIntent?> GetAsync(string intentId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(intentId);
_intents.TryGetValue(intentId, out var intent);
return Task.FromResult(intent);
}
}

View File

@@ -0,0 +1,114 @@
// <copyright file="NullPolicyIntentParser.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.AdvisoryAI.PolicyStudio;
/// <summary>
/// Deterministic stub parser for policy intents when inference is unavailable.
/// </summary>
public sealed class NullPolicyIntentParser : IPolicyIntentParser
{
private readonly IPolicyIntentStore _intentStore;
private readonly TimeProvider _timeProvider;
public NullPolicyIntentParser(IPolicyIntentStore intentStore, TimeProvider timeProvider)
{
_intentStore = intentStore ?? throw new ArgumentNullException(nameof(intentStore));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<PolicyParseResult> ParseAsync(
string naturalLanguageInput,
PolicyParseContext? context = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(naturalLanguageInput);
var intent = BuildIntent(naturalLanguageInput, context);
await _intentStore.StoreAsync(intent, cancellationToken).ConfigureAwait(false);
return new PolicyParseResult
{
Intent = intent,
Success = true,
ErrorMessage = null,
ModelId = "stub-policy-parser:v0",
ParsedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
};
}
public async Task<PolicyParseResult> ClarifyAsync(
string intentId,
string clarification,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(intentId);
ArgumentException.ThrowIfNullOrWhiteSpace(clarification);
var original = await _intentStore.GetAsync(intentId, cancellationToken).ConfigureAwait(false);
if (original is null)
{
throw new InvalidOperationException($"Intent {intentId} not found");
}
var clarified = original with
{
Confidence = Math.Min(1.0, original.Confidence + 0.1),
ClarifyingQuestions = null
};
await _intentStore.StoreAsync(clarified, cancellationToken).ConfigureAwait(false);
return new PolicyParseResult
{
Intent = clarified,
Success = true,
ErrorMessage = null,
ModelId = "stub-policy-parser:v0",
ParsedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
};
}
private static PolicyIntent BuildIntent(string input, PolicyParseContext? context)
{
var intentId = $"intent:stub:{ComputeHash(input)[..12]}";
var scope = string.IsNullOrWhiteSpace(context?.DefaultScope) ? "all" : context!.DefaultScope!;
return new PolicyIntent
{
IntentId = intentId,
IntentType = PolicyIntentType.OverrideRule,
OriginalInput = input,
Conditions =
[
new PolicyCondition
{
Field = "severity",
Operator = "equals",
Value = "critical"
}
],
Actions =
[
new PolicyAction
{
ActionType = "set_verdict",
Parameters = new Dictionary<string, object> { ["verdict"] = "block" }
}
],
Scope = scope,
Priority = 100,
Confidence = 0.8
};
}
private static string ComputeHash(string content)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToHexStringLower(bytes);
}
}

View File

@@ -0,0 +1,18 @@
// <copyright file="NullAdvisoryDocumentProvider.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Documents;
namespace StellaOps.AdvisoryAI.Providers;
/// <summary>
/// Null implementation of advisory document provider.
/// </summary>
internal sealed class NullAdvisoryDocumentProvider : IAdvisoryDocumentProvider
{
public Task<IReadOnlyList<AdvisoryDocument>> GetDocumentsAsync(
string advisoryKey,
CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<AdvisoryDocument>>(Array.Empty<AdvisoryDocument>());
}

View File

@@ -0,0 +1,100 @@
// <copyright file="NullRemediationPlanner.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Remediation;
/// <summary>
/// Deterministic stub planner used when remediation services are not configured.
/// </summary>
public sealed class NullRemediationPlanner : IRemediationPlanner
{
private readonly ConcurrentDictionary<string, RemediationPlan> _plans = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
public NullRemediationPlanner(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<RemediationPlan> GeneratePlanAsync(
RemediationPlanRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var inputHash = ComputeHash(JsonSerializer.Serialize(request));
var planId = $"plan:stub:{inputHash[..12]}";
var plan = new RemediationPlan
{
PlanId = planId,
Request = request,
Steps =
[
new RemediationStep
{
Order = 1,
ActionType = "review_remediation",
FilePath = "N/A",
Description = "Remediation planner is not configured.",
Risk = RemediationRisk.Unknown
}
],
ExpectedDelta = new ExpectedSbomDelta
{
Added = Array.Empty<string>(),
Removed = Array.Empty<string>(),
Upgraded = new Dictionary<string, string>(),
NetVulnerabilityChange = 0
},
RiskAssessment = RemediationRisk.Unknown,
TestRequirements = new RemediationTestRequirements
{
TestSuites = Array.Empty<string>(),
MinCoverage = 0,
RequireAllPass = false,
Timeout = TimeSpan.Zero
},
Authority = RemediationAuthority.Suggestion,
PrReady = false,
NotReadyReason = "Remediation planner is not configured.",
ConfidenceScore = 0.0,
ModelId = "stub-remediation:v0",
GeneratedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture),
InputHashes = new[] { inputHash },
EvidenceRefs = new[] { request.ComponentPurl, request.VulnerabilityId }
};
_plans[planId] = plan;
return Task.FromResult(plan);
}
public Task<bool> ValidatePlanAsync(
string planId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(planId);
return Task.FromResult(_plans.ContainsKey(planId));
}
public Task<RemediationPlan?> GetPlanAsync(
string planId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(planId);
_plans.TryGetValue(planId, out var plan);
return Task.FromResult(plan);
}
private static string ComputeHash(string content)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToHexStringLower(bytes);
}
}

View File

@@ -660,7 +660,7 @@ internal sealed class RunService : IRunService
private static void ValidateCanModify(Run run)
{
if (run.Status is RunStatus.Completed or RunStatus.Cancelled or RunStatus.Failed or RunStatus.Expired)
if (run.Status is RunStatus.Completed or RunStatus.Cancelled or RunStatus.Failed or RunStatus.Expired or RunStatus.Rejected)
{
throw new InvalidOperationException($"Cannot modify run with status: {run.Status}");
}

View File

@@ -22,6 +22,7 @@
<!-- Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-006) -->
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Pack\StellaOps.Evidence.Pack.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Canonicalization\StellaOps.Canonicalization.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,93 @@
-- AdvisoryAI Chat audit tables.
-- Schema defaults to advisoryai (AdvisoryAI:Chat:Audit:SchemaName).
CREATE SCHEMA IF NOT EXISTS advisoryai;
CREATE TABLE IF NOT EXISTS advisoryai.chat_sessions
(
session_id text PRIMARY KEY,
tenant_id text NOT NULL,
user_id text NOT NULL,
conversation_id text NULL,
correlation_id text NULL,
intent text NULL,
decision text NOT NULL,
decision_code text NULL,
decision_reason text NULL,
model_id text NULL,
model_hash text NULL,
prompt_hash text NULL,
response_hash text NULL,
response_id text NULL,
bundle_id text NULL,
redactions_applied integer NULL,
prompt_tokens integer NULL,
completion_tokens integer NULL,
total_tokens integer NULL,
latency_ms bigint NULL,
evidence_bundle_json jsonb NULL,
created_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_sessions_tenant_created
ON advisoryai.chat_sessions (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_chat_sessions_conversation
ON advisoryai.chat_sessions (conversation_id);
CREATE TABLE IF NOT EXISTS advisoryai.chat_messages
(
message_id text PRIMARY KEY,
session_id text NOT NULL REFERENCES advisoryai.chat_sessions (session_id) ON DELETE CASCADE,
role text NOT NULL,
content text NOT NULL,
content_hash text NOT NULL,
redaction_count integer NULL,
created_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_session
ON advisoryai.chat_messages (session_id);
CREATE TABLE IF NOT EXISTS advisoryai.chat_policy_decisions
(
decision_id text PRIMARY KEY,
session_id text NOT NULL REFERENCES advisoryai.chat_sessions (session_id) ON DELETE CASCADE,
policy_type text NOT NULL,
decision text NOT NULL,
reason text NULL,
payload_json jsonb NULL,
created_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_policy_decisions_session
ON advisoryai.chat_policy_decisions (session_id);
CREATE TABLE IF NOT EXISTS advisoryai.chat_tool_invocations
(
invocation_id text PRIMARY KEY,
session_id text NOT NULL REFERENCES advisoryai.chat_sessions (session_id) ON DELETE CASCADE,
tool_name text NOT NULL,
input_hash text NULL,
output_hash text NULL,
payload_json jsonb NULL,
invoked_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_tool_invocations_session
ON advisoryai.chat_tool_invocations (session_id);
CREATE TABLE IF NOT EXISTS advisoryai.chat_evidence_links
(
link_id text PRIMARY KEY,
session_id text NOT NULL REFERENCES advisoryai.chat_sessions (session_id) ON DELETE CASCADE,
link_type text NOT NULL,
link text NOT NULL,
description text NULL,
confidence text NULL,
link_hash text NOT NULL,
created_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_evidence_links_session
ON advisoryai.chat_evidence_links (session_id);

View File

@@ -1,10 +1,12 @@
# Advisory AI Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conversational_interface.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0017-M | DONE | Maintainability audit for StellaOps.AdvisoryAI. |
| AUDIT-0017-T | DONE | Test coverage audit for StellaOps.AdvisoryAI. |
| AUDIT-0017-A | DONE | Pending approval for changes. |
| AIAI-CHAT-AUDIT-0001 | DONE | Persist chat audit tables and logger. |
| AUDIT-TESTGAP-ADVISORYAI-0001 | DONE | Added worker and unified plugin adapter tests. |

View File

@@ -86,6 +86,17 @@ public sealed class AdvisoryGuardrailInjectionTests
options.RequireCitations = testCase.RequireCitations.Value;
}
if (testCase.AllowlistPatterns is { Length: > 0 })
{
foreach (var pattern in testCase.AllowlistPatterns)
{
if (!string.IsNullOrWhiteSpace(pattern))
{
options.AllowlistPatterns.Add(pattern);
}
}
}
return options;
}
@@ -200,5 +211,9 @@ public sealed class AdvisoryGuardrailInjectionTests
[JsonPropertyName("expectRedactionPlaceholder")]
public bool ExpectRedactionPlaceholder { get; init; }
= false;
[JsonPropertyName("allowlistPatterns")]
public string[]? AllowlistPatterns { get; init; }
= null;
}
}

View File

@@ -25,6 +25,8 @@ public sealed class AdvisoryGuardrailOptionsBindingTests
var tempRoot = CreateTempDirectory();
var phrasePath = Path.Combine(tempRoot, "guardrail-phrases.json");
await File.WriteAllTextAsync(phrasePath, "{\n \"phrases\": [\"extract secrets\", \"dump cache\"]\n}");
var allowlistPath = Path.Combine(tempRoot, "guardrail-allowlist.txt");
await File.WriteAllTextAsync(allowlistPath, "sha256:[0-9a-f]{64}\nscan:[A-Za-z0-9_-]{16,}\n");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
@@ -32,7 +34,11 @@ public sealed class AdvisoryGuardrailOptionsBindingTests
["AdvisoryAI:Guardrails:MaxPromptLength"] = "32000",
["AdvisoryAI:Guardrails:RequireCitations"] = "false",
["AdvisoryAI:Guardrails:BlockedPhraseFile"] = "guardrail-phrases.json",
["AdvisoryAI:Guardrails:BlockedPhrases:0"] = "custom override"
["AdvisoryAI:Guardrails:BlockedPhrases:0"] = "custom override",
["AdvisoryAI:Guardrails:AllowlistFile"] = "guardrail-allowlist.txt",
["AdvisoryAI:Guardrails:AllowlistPatterns:0"] = "custom-allowlist",
["AdvisoryAI:Guardrails:EntropyThreshold"] = "3.9",
["AdvisoryAI:Guardrails:EntropyMinLength"] = "24"
})
.Build();
@@ -48,6 +54,11 @@ public sealed class AdvisoryGuardrailOptionsBindingTests
options.BlockedPhrases.Should().Contain("custom override");
options.BlockedPhrases.Should().Contain("extract secrets");
options.BlockedPhrases.Should().Contain("dump cache");
options.EntropyThreshold.Should().Be(3.9);
options.EntropyMinLength.Should().Be(24);
options.AllowlistPatterns.Should().Contain("custom-allowlist");
options.AllowlistPatterns.Should().Contain("sha256:[0-9a-f]{64}");
options.AllowlistPatterns.Should().Contain("scan:[A-Za-z0-9_-]{16,}");
}
[Trait("Category", TestCategories.Unit)]
@@ -71,6 +82,27 @@ public sealed class AdvisoryGuardrailOptionsBindingTests
action.Should().Throw<FileNotFoundException>();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AddAdvisoryAiCore_ThrowsWhenAllowlistFileMissing()
{
var tempRoot = CreateTempDirectory();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AdvisoryAI:Guardrails:AllowlistFile"] = "missing.txt"
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<IHostEnvironment>(new FakeHostEnvironment(tempRoot));
services.AddAdvisoryAiCore(configuration);
await using var provider = services.BuildServiceProvider();
var action = () => provider.GetRequiredService<IOptions<AdvisoryGuardrailOptions>>().Value;
action.Should().Throw<FileNotFoundException>();
}
private static string CreateTempDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "advisoryai-guardrails", Guid.NewGuid().ToString("n"));

View File

@@ -331,6 +331,9 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable
public Task<AdvisoryGuardrailResult> EvaluateAsync(AdvisoryPrompt prompt, CancellationToken cancellationToken)
=> Task.FromResult(_result);
public AdvisoryRedactionResult Redact(string input)
=> new(input ?? string.Empty, 0);
}
private sealed class StubInferenceClient : IAdvisoryInferenceClient

View File

@@ -0,0 +1,241 @@
// <copyright file="AdvisoryChatAuditEnvelopeBuilderTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.AdvisoryAI.Chat.Audit;
using StellaOps.AdvisoryAI.Chat.Models;
using StellaOps.AdvisoryAI.Chat.Routing;
using StellaOps.AdvisoryAI.Chat.Services;
using StellaOps.AdvisoryAI.Chat.Settings;
using StellaOps.AdvisoryAI.Guardrails;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Chat.Audit;
[Trait("Category", "Unit")]
public sealed class AdvisoryChatAuditEnvelopeBuilderTests
{
[Fact]
public void BuildSuccess_RecordsEvidenceAndDecisions()
{
var now = new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero);
var request = BuildRequest();
var routing = BuildRouting();
var response = BuildResponse(now);
var diagnostics = new AdvisoryChatDiagnostics
{
PromptTokens = 12,
CompletionTokens = 34,
TotalMs = 50
};
var toolPolicy = BuildToolPolicy();
var quotaStatus = BuildQuotaStatus(now);
var evidenceBundle = BuildEvidenceBundle(now);
var envelope = AdvisoryChatAuditEnvelopeBuilder.BuildSuccess(
request,
routing,
"sanitized prompt",
evidenceBundle,
response,
diagnostics,
quotaStatus,
toolPolicy,
now,
includeEvidenceBundle: false);
Assert.Equal("success", envelope.Session.Decision);
Assert.Equal(request.TenantId, envelope.Session.TenantId);
Assert.Equal(2, envelope.Messages.Length);
Assert.Single(envelope.EvidenceLinks);
Assert.Single(envelope.ToolInvocations);
Assert.Contains(envelope.PolicyDecisions, decision => decision.PolicyType == "tool_access");
Assert.Null(envelope.Session.EvidenceBundleJson);
}
[Fact]
public void BuildGuardrailBlocked_RecordsDenialAndToolPolicy()
{
var now = new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero);
var request = BuildRequest();
var routing = BuildRouting();
var toolPolicy = BuildToolPolicy();
var quotaStatus = BuildQuotaStatus(now);
var guardrailResult = AdvisoryGuardrailResult.Reject(
"sanitized prompt",
[new AdvisoryGuardrailViolation("prompt_too_long", "Prompt too long.")],
ImmutableDictionary<string, string>.Empty.Add("redaction_count", "2"));
var envelope = AdvisoryChatAuditEnvelopeBuilder.BuildGuardrailBlocked(
request,
routing,
guardrailResult.SanitizedPrompt,
guardrailResult,
BuildEvidenceBundle(now),
toolPolicy,
quotaStatus,
now,
includeEvidenceBundle: false);
Assert.Equal("guardrail_blocked", envelope.Session.Decision);
Assert.Equal("GUARDRAIL_BLOCKED", envelope.Session.DecisionCode);
Assert.Single(envelope.Messages);
Assert.Equal(toolPolicy.AllowedTools.Length, envelope.ToolInvocations.Length);
Assert.Contains(envelope.PolicyDecisions, decision => decision.PolicyType == "guardrail" && decision.Decision == "deny");
}
[Fact]
public void BuildQuotaDenied_RecordsQuotaDecision()
{
var now = new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero);
var request = BuildRequest();
var routing = BuildRouting();
var toolPolicy = BuildToolPolicy();
var decision = new ChatQuotaDecision
{
Allowed = false,
Code = "TOKENS_PER_DAY_EXCEEDED",
Message = "Quota exceeded.",
Status = BuildQuotaStatus(now)
};
var envelope = AdvisoryChatAuditEnvelopeBuilder.BuildQuotaDenied(
request,
routing,
new AdvisoryRedactionResult("sanitized", 1),
decision,
toolPolicy,
now);
Assert.Equal("quota_denied", envelope.Session.Decision);
Assert.Equal(decision.Code, envelope.Session.DecisionCode);
Assert.Contains(envelope.PolicyDecisions, policy => policy.PolicyType == "quota" && policy.Decision == "deny");
Assert.Contains(envelope.PolicyDecisions, policy => policy.PolicyType == "tool_access");
}
[Fact]
public void BuildToolAccessDenied_RecordsToolPolicyDecision()
{
var now = new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero);
var request = BuildRequest();
var routing = BuildRouting();
var toolPolicy = BuildToolPolicy();
var envelope = AdvisoryChatAuditEnvelopeBuilder.BuildToolAccessDenied(
request,
routing,
new AdvisoryRedactionResult("sanitized", 2),
toolPolicy,
"sbom.read not allowed",
now);
Assert.Equal("tool_access_denied", envelope.Session.Decision);
Assert.Equal("sbom.read not allowed", envelope.Session.DecisionReason);
Assert.Contains(envelope.PolicyDecisions, policy => policy.PolicyType == "tool_access" && policy.Decision == "deny");
}
private static AdvisoryChatRequest BuildRequest()
=> new()
{
TenantId = "tenant-1",
UserId = "user-1",
Query = "Why is CVE-2024-0001 still listed?",
ArtifactDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
ImageReference = "repo/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Environment = "prod",
CorrelationId = "corr-1",
ConversationId = "conv-1"
};
private static IntentRoutingResult BuildRouting()
=> new()
{
Intent = AdvisoryChatIntent.Explain,
Confidence = 0.9,
Parameters = new IntentParameters
{
FindingId = "CVE-2024-0001",
ImageReference = "repo/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
NormalizedInput = "why is cve-2024-0001 still listed",
ExplicitSlashCommand = false
};
private static AdvisoryChatEvidenceBundle BuildEvidenceBundle(DateTimeOffset now)
=> new()
{
BundleId = "bundle-1",
AssembledAt = now,
Artifact = new EvidenceArtifact
{
Digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Environment = "prod"
},
Finding = new EvidenceFinding
{
Type = EvidenceFindingType.Cve,
Id = "CVE-2024-0001"
}
};
private static AdvisoryChatResponse BuildResponse(DateTimeOffset now)
=> new()
{
ResponseId = "resp-1",
BundleId = "bundle-1",
Intent = AdvisoryChatIntent.Explain,
GeneratedAt = now,
Summary = "Summary text.",
EvidenceLinks = ImmutableArray.Create(new EvidenceLink
{
Type = EvidenceLinkType.Sbom,
Link = "[sbom:bundle-1]",
Description = "SBOM for artifact",
Confidence = ConfidenceLevel.High
}),
Confidence = new ConfidenceAssessment
{
Level = ConfidenceLevel.High,
Score = 0.95
},
Audit = new ResponseAudit
{
ModelId = "model-1",
RedactionsApplied = 1
}
};
private static ChatToolPolicyResult BuildToolPolicy()
=> new()
{
AllowAll = false,
AllowSbom = true,
AllowVex = true,
AllowReachability = false,
AllowBinaryPatch = false,
AllowOpsMemory = false,
AllowPolicy = false,
AllowProvenance = false,
AllowFix = false,
AllowContext = false,
ToolCallCount = 2,
AllowedTools = ImmutableArray.Create("sbom.read", "vex.query")
};
private static ChatQuotaStatus BuildQuotaStatus(DateTimeOffset now)
=> new()
{
RequestsPerMinuteLimit = 60,
RequestsPerMinuteRemaining = 59,
RequestsPerMinuteResetsAt = now.AddMinutes(1),
RequestsPerDayLimit = 500,
RequestsPerDayRemaining = 499,
RequestsPerDayResetsAt = now.AddDays(1),
TokensPerDayLimit = 1000,
TokensPerDayRemaining = 900,
TokensPerDayResetsAt = now.AddDays(1),
ToolCallsPerDayLimit = 100,
ToolCallsPerDayRemaining = 99,
ToolCallsPerDayResetsAt = now.AddDays(1)
};
}

View File

@@ -12,6 +12,7 @@ using Microsoft.Extensions.DependencyInjection;
using StellaOps.AdvisoryAI.Chat;
using StellaOps.AdvisoryAI.Storage;
using StellaOps.AdvisoryAI.WebService.Contracts;
using StellaOps.AdvisoryAI.WebService.Endpoints;
using StellaOps.TestKit;
using Xunit;
@@ -22,12 +23,12 @@ namespace StellaOps.AdvisoryAI.Tests.Chat;
/// Sprint: SPRINT_20260107_006_003 Task CH-015
/// </summary>
[Trait("Category", TestCategories.Integration)]
public sealed class ChatIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
public sealed class ChatIntegrationTests : IClassFixture<WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program> _factory;
private readonly HttpClient _client;
public ChatIntegrationTests(WebApplicationFactory<Program> factory)
public ChatIntegrationTests(WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{

View File

@@ -18,6 +18,7 @@ using StellaOps.AdvisoryAI.Chat.Models;
using StellaOps.AdvisoryAI.Chat.Options;
using StellaOps.AdvisoryAI.Chat.Routing;
using StellaOps.AdvisoryAI.Chat.Services;
using StellaOps.AdvisoryAI.Chat.Settings;
using StellaOps.AdvisoryAI.WebService.Endpoints;
using Xunit;
@@ -39,6 +40,7 @@ public sealed class AdvisoryChatEndpointsIntegrationTests : IAsyncLifetime
{
// Register mock services
services.AddLogging();
services.AddRouting();
// Register options directly for testing
services.Configure<AdvisoryChatOptions>(options =>
@@ -52,6 +54,11 @@ public sealed class AdvisoryChatEndpointsIntegrationTests : IAsyncLifetime
};
});
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAdvisoryChatSettingsStore, InMemoryAdvisoryChatSettingsStore>();
services.AddSingleton<IAdvisoryChatSettingsService, AdvisoryChatSettingsService>();
services.AddSingleton<IAdvisoryChatQuotaService, AdvisoryChatQuotaService>();
// Register mock chat service
var mockChatService = new Mock<IAdvisoryChatService>();
mockChatService

View File

@@ -0,0 +1,133 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StellaOps.AdvisoryAI.Chat.Options;
using StellaOps.AdvisoryAI.Chat.Services;
using StellaOps.AdvisoryAI.Chat.Settings;
using StellaOps.AdvisoryAI.WebService.Endpoints;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Chat.Integration;
[Trait("Category", "Integration")]
public sealed class AdvisoryChatErrorResponseTests
{
[Fact]
public async Task PostQuery_QuotaBlocked_IncludesDoctorAction()
{
var quotaStatus = CreateQuotaStatus();
var result = new AdvisoryChatServiceResult
{
Success = false,
Error = "Quota exceeded",
QuotaBlocked = true,
QuotaCode = "TOKENS_PER_DAY_EXCEEDED",
QuotaStatus = quotaStatus
};
var (host, client) = await CreateHostAsync(result);
try
{
var response = await client.PostAsJsonAsync("/api/v1/chat/query", new
{
query = "Why is CVE-2026-0001 still present?",
artifactDigest = "sha256:abc123"
});
Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>();
Assert.NotNull(error);
Assert.NotNull(error!.Doctor);
Assert.Equal("/api/v1/chat/doctor", error.Doctor!.Endpoint);
Assert.Equal("stella advise doctor", error.Doctor.SuggestedCommand);
Assert.Equal("TOKENS_PER_DAY_EXCEEDED", error.Doctor.Reason);
}
finally
{
client.Dispose();
await host.StopAsync();
host.Dispose();
}
}
private static async Task<(IHost host, HttpClient client)> CreateHostAsync(AdvisoryChatServiceResult result)
{
var builder = new HostBuilder()
.ConfigureWebHost(webHost =>
{
webHost.UseTestServer();
webHost.ConfigureServices(services =>
{
services.AddLogging();
services.AddRouting();
services.Configure<AdvisoryChatOptions>(options =>
{
options.Enabled = true;
options.Inference = new InferenceOptions
{
Provider = "local",
Model = "test-model",
MaxTokens = 2000
};
});
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAdvisoryChatSettingsStore, InMemoryAdvisoryChatSettingsStore>();
services.AddSingleton<IAdvisoryChatSettingsService, AdvisoryChatSettingsService>();
services.AddSingleton<IAdvisoryChatQuotaService, AdvisoryChatQuotaService>();
services.AddSingleton<IAdvisoryChatService>(new StaticChatService(result));
});
webHost.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapChatEndpoints());
});
});
var host = await builder.StartAsync();
return (host, host.GetTestClient());
}
private static ChatQuotaStatus CreateQuotaStatus()
{
var now = new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero);
return new ChatQuotaStatus
{
RequestsPerMinuteLimit = 1,
RequestsPerMinuteRemaining = 0,
RequestsPerMinuteResetsAt = now.AddMinutes(1),
RequestsPerDayLimit = 1,
RequestsPerDayRemaining = 0,
RequestsPerDayResetsAt = now.AddDays(1),
TokensPerDayLimit = 1,
TokensPerDayRemaining = 0,
TokensPerDayResetsAt = now.AddDays(1),
ToolCallsPerDayLimit = 1,
ToolCallsPerDayRemaining = 0,
ToolCallsPerDayResetsAt = now.AddDays(1),
LastDenied = new ChatQuotaDenial
{
Code = "TOKENS_PER_DAY_EXCEEDED",
Message = "Quota exceeded",
DeniedAt = now
}
};
}
private sealed class StaticChatService : IAdvisoryChatService
{
private readonly AdvisoryChatServiceResult _result;
public StaticChatService(AdvisoryChatServiceResult result)
{
_result = result;
}
public Task<AdvisoryChatServiceResult> ProcessQueryAsync(AdvisoryChatRequest request, CancellationToken cancellationToken)
=> Task.FromResult(_result);
}
}

View File

@@ -22,6 +22,8 @@ public sealed class AdvisoryChatOptionsTests
Assert.NotNull(options.Inference);
Assert.NotNull(options.DataProviders);
Assert.NotNull(options.Guardrails);
Assert.NotNull(options.Quotas);
Assert.NotNull(options.Tools);
Assert.NotNull(options.Audit);
}
@@ -48,6 +50,7 @@ public sealed class AdvisoryChatOptionsTests
// Assert
Assert.True(options.VexEnabled);
Assert.True(options.SbomEnabled);
Assert.True(options.ReachabilityEnabled);
Assert.True(options.BinaryPatchEnabled);
Assert.True(options.OpsMemoryEnabled);
@@ -70,6 +73,29 @@ public sealed class AdvisoryChatOptionsTests
Assert.True(options.BlockHarmfulPrompts);
}
[Fact]
public void QuotaOptions_HaveReasonableDefaults()
{
// Arrange & Act
var options = new QuotaOptions();
// Assert
Assert.True(options.RequestsPerMinute >= 0);
Assert.True(options.RequestsPerDay >= 0);
Assert.True(options.TokensPerDay >= 0);
Assert.True(options.ToolCallsPerDay >= 0);
}
[Fact]
public void ToolAccessOptions_HaveReasonableDefaults()
{
// Arrange & Act
var options = new ToolAccessOptions();
// Assert
Assert.NotNull(options.AllowedTools);
}
[Fact]
public void AuditOptions_HaveReasonableDefaults()
{
@@ -218,6 +244,34 @@ public sealed class AdvisoryChatOptionsValidatorTests
Assert.Contains("Provider", result.FailureMessage);
}
[Fact]
public void Validate_NegativeQuota_ReturnsFailed()
{
// Arrange
var options = new AdvisoryChatOptions
{
Inference = new InferenceOptions
{
Provider = "local",
Model = "test-model",
MaxTokens = 2000,
Temperature = 0.1,
TimeoutSeconds = 30
},
Quotas = new QuotaOptions
{
RequestsPerDay = -1
}
};
// Act
var result = _validator.Validate(null, options);
// Assert
Assert.True(result.Failed);
Assert.Contains("Quotas.RequestsPerDay", result.FailureMessage);
}
[Fact]
public void Validate_LocalProviderWithoutApiKey_ReturnsSuccess()
{

View File

@@ -0,0 +1,77 @@
// <copyright file="AdvisoryChatQuotaServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.Time.Testing;
using StellaOps.AdvisoryAI.Chat.Services;
using StellaOps.AdvisoryAI.Chat.Settings;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Chat.Services;
[Trait("Category", "Unit")]
public sealed class AdvisoryChatQuotaServiceTests
{
[Fact]
public async Task TryConsumeAsync_EnforcesRequestsPerMinute()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 10, 0, 0, TimeSpan.Zero));
var service = new AdvisoryChatQuotaService(timeProvider);
var settings = new ChatQuotaSettings
{
RequestsPerMinute = 2,
RequestsPerDay = 10,
TokensPerDay = 100,
ToolCallsPerDay = 10
};
var request = new ChatQuotaRequest
{
TenantId = "tenant-a",
UserId = "user-a",
EstimatedTokens = 1,
ToolCalls = 1
};
var decision1 = await service.TryConsumeAsync(request, settings);
var decision2 = await service.TryConsumeAsync(request, settings);
var decision3 = await service.TryConsumeAsync(request, settings);
Assert.True(decision1.Allowed);
Assert.True(decision2.Allowed);
Assert.False(decision3.Allowed);
Assert.Equal("REQUESTS_PER_MINUTE_EXCEEDED", decision3.Code);
timeProvider.Advance(TimeSpan.FromMinutes(1));
var decision4 = await service.TryConsumeAsync(request, settings);
Assert.True(decision4.Allowed);
}
[Fact]
public async Task TryConsumeAsync_EnforcesTokensPerDay()
{
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 10, 0, 0, TimeSpan.Zero));
var service = new AdvisoryChatQuotaService(timeProvider);
var settings = new ChatQuotaSettings
{
RequestsPerMinute = 10,
RequestsPerDay = 10,
TokensPerDay = 5,
ToolCallsPerDay = 10
};
var request = new ChatQuotaRequest
{
TenantId = "tenant-a",
UserId = "user-a",
EstimatedTokens = 4,
ToolCalls = 1
};
var decision1 = await service.TryConsumeAsync(request, settings);
var decision2 = await service.TryConsumeAsync(request, settings);
Assert.True(decision1.Allowed);
Assert.False(decision2.Allowed);
Assert.Equal("TOKENS_PER_DAY_EXCEEDED", decision2.Code);
}
}

View File

@@ -0,0 +1,105 @@
// <copyright file="AdvisoryChatSettingsServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using MsOptions = Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Chat.Options;
using StellaOps.AdvisoryAI.Chat.Settings;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Chat.Settings;
[Trait("Category", "Unit")]
public sealed class AdvisoryChatSettingsServiceTests
{
[Fact]
public async Task GetEffectiveSettingsAsync_UsesDefaultsWhenNoOverrides()
{
var options = MsOptions.Options.Create(new AdvisoryChatOptions
{
Quotas = new QuotaOptions
{
RequestsPerMinute = 12,
RequestsPerDay = 100,
TokensPerDay = 1000,
ToolCallsPerDay = 200
},
Tools = new ToolAccessOptions
{
AllowAll = false,
AllowedTools = ["vex.query", "sbom.read"]
}
});
var store = new InMemoryAdvisoryChatSettingsStore();
var service = new AdvisoryChatSettingsService(store, options);
var settings = await service.GetEffectiveSettingsAsync("tenant-a", "user-a");
Assert.Equal(12, settings.Quotas.RequestsPerMinute);
Assert.Equal(100, settings.Quotas.RequestsPerDay);
Assert.Equal(1000, settings.Quotas.TokensPerDay);
Assert.Equal(200, settings.Quotas.ToolCallsPerDay);
Assert.False(settings.Tools.AllowAll);
Assert.Contains("sbom.read", settings.Tools.AllowedTools);
Assert.Contains("vex.query", settings.Tools.AllowedTools);
}
[Fact]
public async Task GetEffectiveSettingsAsync_AppliesTenantAndUserOverrides()
{
var options = MsOptions.Options.Create(new AdvisoryChatOptions
{
Quotas = new QuotaOptions
{
RequestsPerMinute = 10,
RequestsPerDay = 100,
TokensPerDay = 1000,
ToolCallsPerDay = 50
},
Tools = new ToolAccessOptions
{
AllowAll = true,
AllowedTools = []
}
});
var store = new InMemoryAdvisoryChatSettingsStore();
var service = new AdvisoryChatSettingsService(store, options);
await service.SetTenantOverridesAsync("tenant-a", new AdvisoryChatSettingsOverrides
{
Quotas = new ChatQuotaOverrides
{
RequestsPerMinute = 5
},
Tools = new ChatToolAccessOverrides
{
AllowAll = false,
AllowedTools = ImmutableArray.Create("sbom.read")
}
});
await service.SetUserOverridesAsync("tenant-a", "user-a", new AdvisoryChatSettingsOverrides
{
Quotas = new ChatQuotaOverrides
{
RequestsPerMinute = 3
},
Tools = new ChatToolAccessOverrides
{
AllowedTools = ImmutableArray.Create("vex.query")
}
});
var settings = await service.GetEffectiveSettingsAsync("tenant-a", "user-a");
Assert.Equal(3, settings.Quotas.RequestsPerMinute);
Assert.Equal(100, settings.Quotas.RequestsPerDay);
Assert.Equal(1000, settings.Quotas.TokensPerDay);
Assert.Equal(50, settings.Quotas.ToolCallsPerDay);
Assert.False(settings.Tools.AllowAll);
Assert.Single(settings.Tools.AllowedTools);
Assert.Equal("vex.query", settings.Tools.AllowedTools[0]);
}
}

View File

@@ -0,0 +1,85 @@
// <copyright file="AdvisoryChatToolPolicyTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.AdvisoryAI.Chat.Options;
using StellaOps.AdvisoryAI.Chat.Settings;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Chat.Settings;
[Trait("Category", "Unit")]
public sealed class AdvisoryChatToolPolicyTests
{
[Fact]
public void Resolve_WithAllowAll_UsesProviderDefaults()
{
var tools = new ChatToolAccessSettings
{
AllowAll = true,
AllowedTools = ImmutableArray<string>.Empty
};
var providers = new DataProviderOptions
{
SbomEnabled = true,
VexEnabled = true,
ReachabilityEnabled = false,
BinaryPatchEnabled = true,
OpsMemoryEnabled = false,
PolicyEnabled = true,
ProvenanceEnabled = true,
FixEnabled = false,
ContextEnabled = true
};
var policy = AdvisoryChatToolPolicy.Resolve(
tools,
providers,
includeReachability: true,
includeBinaryPatch: true,
includeOpsMemory: true);
Assert.True(policy.AllowAll);
Assert.True(policy.AllowSbom);
Assert.True(policy.AllowVex);
Assert.False(policy.AllowReachability);
Assert.True(policy.AllowBinaryPatch);
Assert.False(policy.AllowOpsMemory);
Assert.True(policy.AllowPolicy);
Assert.True(policy.AllowProvenance);
Assert.False(policy.AllowFix);
Assert.True(policy.AllowContext);
}
[Fact]
public void Resolve_WithAllowList_RestrictsTools()
{
var tools = new ChatToolAccessSettings
{
AllowAll = false,
AllowedTools = ImmutableArray.Create("vex.query", "sbom.read")
};
var providers = new DataProviderOptions
{
SbomEnabled = true,
VexEnabled = true,
ReachabilityEnabled = true
};
var policy = AdvisoryChatToolPolicy.Resolve(
tools,
providers,
includeReachability: true,
includeBinaryPatch: true,
includeOpsMemory: true);
Assert.False(policy.AllowAll);
Assert.True(policy.AllowSbom);
Assert.True(policy.AllowVex);
Assert.False(policy.AllowReachability);
Assert.Contains("sbom.read", policy.AllowedTools);
Assert.Contains("vex.query", policy.AllowedTools);
}
}

View File

@@ -18,10 +18,14 @@
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.Hosting\StellaOps.AdvisoryAI.Hosting.csproj" />
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.Plugin.Unified\StellaOps.AdvisoryAI.Plugin.Unified.csproj" />
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.Scm.Plugin.Unified\StellaOps.AdvisoryAI.Scm.Plugin.Unified.csproj" />
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.WebService\StellaOps.AdvisoryAI.WebService.csproj" />
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.Worker\StellaOps.AdvisoryAI.Worker.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="..\..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\..\Plugin\StellaOps.Plugin.Testing\StellaOps.Plugin.Testing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -15,6 +15,19 @@
"expectedRedactionCount": 1,
"expectRedactionPlaceholder": true
},
{
"name": "HighEntropyRedaction",
"payload": "session=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",
"blocked": false,
"expectedRedactionCount": 1,
"expectRedactionPlaceholder": true
},
{
"name": "AllowlistDigest",
"payload": "ref sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"blocked": false,
"expectedRedactionCount": 0
},
{
"name": "MissingCitations",
"payload": "Provide an answer without citations or references.",

View File

@@ -4,7 +4,7 @@
"payload": "Summarize CVE-2025-9999 with references [1] and [2]. ",
"repeat": 512,
"iterations": 160,
"maxDurationMs": 400,
"maxDurationMs": 1200,
"includeCitations": true,
"maxPromptLength": 32000,
"requireCitations": true
@@ -14,7 +14,7 @@
"payload": "VGhpcyBpcyBhIGJhc2U2NCBzZWdtZW50IC0gZG8gbm90IGR1bXAgc2VjcmV0cw== ",
"repeat": 256,
"iterations": 96,
"maxDurationMs": 350,
"maxDurationMs": 1200,
"includeCitations": false,
"maxPromptLength": 20000,
"requireCitations": false

View File

@@ -0,0 +1,211 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.AdvisoryAI.Caching;
using StellaOps.AdvisoryAI.Execution;
using StellaOps.AdvisoryAI.Documents;
using StellaOps.AdvisoryAI.Metrics;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Queue;
using StellaOps.AdvisoryAI.Worker.Services;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Worker;
[Trait("Category", "Unit")]
public sealed class AdvisoryTaskWorkerTests
{
[Fact]
public async Task ExecuteAsync_WhenCacheMiss_StoresAliasAndExecutesPlan()
{
var request = CreateRequest(forceRefresh: false);
var message = new AdvisoryTaskQueueMessage("cache-original", request);
var queue = new SingleMessageQueue(message);
var plan = CreatePlan(request, "cache-new");
var cache = new Mock<IAdvisoryPlanCache>();
cache.Setup(c => c.TryGetAsync(message.PlanCacheKey, It.IsAny<CancellationToken>()))
.ReturnsAsync((AdvisoryTaskPlan?)null);
var storedKeys = new List<string>();
cache.Setup(c => c.SetAsync(It.IsAny<string>(), It.IsAny<AdvisoryTaskPlan>(), It.IsAny<CancellationToken>()))
.Callback<string, AdvisoryTaskPlan, CancellationToken>((key, _, _) => storedKeys.Add(key))
.Returns(Task.CompletedTask);
var orchestrator = new Mock<IAdvisoryPipelineOrchestrator>();
orchestrator.Setup(o => o.CreatePlanAsync(request, It.IsAny<CancellationToken>()))
.ReturnsAsync(plan);
var executor = new Mock<IAdvisoryPipelineExecutor>();
var executed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
bool? fromCache = null;
executor.Setup(e => e.ExecuteAsync(plan, message, It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.Callback<AdvisoryTaskPlan, AdvisoryTaskQueueMessage, bool, CancellationToken>((_, _, cached, _) =>
{
fromCache = cached;
executed.TrySetResult(true);
})
.Returns(Task.CompletedTask);
var metrics = new AdvisoryPipelineMetrics(new TestMeterFactory());
var jitterSource = new FixedJitterSource(0.25);
var worker = new TestAdvisoryTaskWorker(
queue,
cache.Object,
orchestrator.Object,
metrics,
executor.Object,
TimeProvider.System,
jitterSource);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var runTask = worker.RunAsync(cts.Token);
await executed.Task;
cts.Cancel();
var completed = await Task.WhenAny(runTask, Task.Delay(TimeSpan.FromSeconds(5)));
Assert.Same(runTask, completed);
await runTask;
Assert.False(fromCache);
Assert.Contains("cache-new", storedKeys);
Assert.Contains("cache-original", storedKeys);
}
[Fact]
public async Task ExecuteAsync_WhenCacheHit_UsesCachedPlan()
{
var request = CreateRequest(forceRefresh: false);
var message = new AdvisoryTaskQueueMessage("cache-hit", request);
var queue = new SingleMessageQueue(message);
var plan = CreatePlan(request, "cache-hit");
var cache = new Mock<IAdvisoryPlanCache>();
cache.Setup(c => c.TryGetAsync(message.PlanCacheKey, It.IsAny<CancellationToken>()))
.ReturnsAsync(plan);
var orchestrator = new Mock<IAdvisoryPipelineOrchestrator>(MockBehavior.Strict);
var executor = new Mock<IAdvisoryPipelineExecutor>();
var executed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
bool? fromCache = null;
executor.Setup(e => e.ExecuteAsync(plan, message, It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.Callback<AdvisoryTaskPlan, AdvisoryTaskQueueMessage, bool, CancellationToken>((_, _, cached, _) =>
{
fromCache = cached;
executed.TrySetResult(true);
})
.Returns(Task.CompletedTask);
var metrics = new AdvisoryPipelineMetrics(new TestMeterFactory());
var jitterSource = new FixedJitterSource(0.1);
var worker = new TestAdvisoryTaskWorker(
queue,
cache.Object,
orchestrator.Object,
metrics,
executor.Object,
TimeProvider.System,
jitterSource);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var runTask = worker.RunAsync(cts.Token);
await executed.Task;
cts.Cancel();
var completed = await Task.WhenAny(runTask, Task.Delay(TimeSpan.FromSeconds(5)));
Assert.Same(runTask, completed);
await runTask;
Assert.True(fromCache);
cache.Verify(c => c.SetAsync(It.IsAny<string>(), It.IsAny<AdvisoryTaskPlan>(), It.IsAny<CancellationToken>()), Times.Never);
orchestrator.VerifyNoOtherCalls();
}
private static AdvisoryTaskRequest CreateRequest(bool forceRefresh)
=> new(AdvisoryTaskType.Remediation, "CVE-2026-0001", forceRefresh: forceRefresh);
private static AdvisoryTaskPlan CreatePlan(AdvisoryTaskRequest request, string cacheKey)
=> new(
request,
cacheKey,
"template",
ImmutableArray<AdvisoryChunk>.Empty,
ImmutableArray<AdvisoryVectorResult>.Empty,
sbomContext: null,
dependencyAnalysis: null,
budget: new AdvisoryTaskBudget { PromptTokens = 1, CompletionTokens = 1 },
metadata: ImmutableDictionary<string, string>.Empty);
private sealed class SingleMessageQueue : IAdvisoryTaskQueue
{
private readonly AdvisoryTaskQueueMessage _message;
private int _dequeued;
public SingleMessageQueue(AdvisoryTaskQueueMessage message)
{
_message = message;
}
public ValueTask EnqueueAsync(AdvisoryTaskQueueMessage message, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public async ValueTask<AdvisoryTaskQueueMessage?> DequeueAsync(CancellationToken cancellationToken)
{
if (Interlocked.Exchange(ref _dequeued, 1) == 0)
{
return _message;
}
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return null;
}
}
private sealed class FixedJitterSource : IAdvisoryJitterSource
{
private readonly double _value;
public FixedJitterSource(double value)
{
_value = value;
}
public double NextDouble() => _value;
}
private sealed class TestMeterFactory : IMeterFactory
{
public Meter Create(string name, string? version = null, IEnumerable<KeyValuePair<string, object?>>? tags = null)
=> new(name, version, tags);
public Meter Create(MeterOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return new Meter(options.Name);
}
public void Dispose()
{
}
}
private sealed class TestAdvisoryTaskWorker : AdvisoryTaskWorker
{
public TestAdvisoryTaskWorker(
IAdvisoryTaskQueue queue,
IAdvisoryPlanCache cache,
IAdvisoryPipelineOrchestrator orchestrator,
AdvisoryPipelineMetrics metrics,
IAdvisoryPipelineExecutor executor,
TimeProvider timeProvider,
IAdvisoryJitterSource jitterSource)
: base(queue, cache, orchestrator, metrics, executor, timeProvider, NullLogger<AdvisoryTaskWorker>.Instance, jitterSource)
{
}
public Task RunAsync(CancellationToken cancellationToken) => ExecuteAsync(cancellationToken);
}
}