audit, advisories and doctors/setup work
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user