Refactor and enhance LDAP plugin configuration and validation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Updated `LdapPluginOptions` to enforce TLS and client certificate requirements.
- Added validation checks for TLS configuration in `LdapPluginOptionsTests`.
- Improved error handling in `DirectoryServicesLdapConnectionFactory` for StartTLS negotiation.
- Enhanced logging in `LdapCredentialStore` to include detailed audit properties for credential verification.
- Introduced `StubStructuredRetriever` and `StubVectorRetriever` for testing in `ToolsetServiceCollectionExtensionsTests`.
- Refactored `AdvisoryGuardrailPipelineTests` to improve test clarity and structure.
- Added `FileSystemAdvisoryTaskQueueTests` for testing queue functionality.
- Updated JSON test data for consistency with new requirements.
- Modified `AdvisoryPipelineOrchestratorTests` to reflect changes in metadata keys.
This commit is contained in:
master
2025-11-05 09:29:51 +02:00
parent 3bd0955202
commit 40e7f827da
37 changed files with 744 additions and 315 deletions

View File

@@ -59,8 +59,8 @@ public static class ToolsetServiceCollectionExtensions
services.TryAddSingleton<IAdvisoryPipelineExecutor, AdvisoryPipelineExecutor>();
services.AddOptions<AdvisoryGuardrailOptions>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<AdvisoryPlanCacheOptions>, ConfigureOptions<AdvisoryPlanCacheOptions>>(
_ => options =>
services.AddOptions<AdvisoryPlanCacheOptions>()
.Configure(options =>
{
if (options.DefaultTimeToLive <= TimeSpan.Zero)
{
@@ -71,10 +71,10 @@ public static class ToolsetServiceCollectionExtensions
{
options.CleanupInterval = TimeSpan.FromMinutes(5);
}
}));
});
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<AdvisoryTaskQueueOptions>, ConfigureOptions<AdvisoryTaskQueueOptions>>(
_ => options =>
services.AddOptions<AdvisoryTaskQueueOptions>()
.Configure(options =>
{
if (options.Capacity <= 0)
{
@@ -85,7 +85,7 @@ public static class ToolsetServiceCollectionExtensions
{
options.DequeueWaitInterval = TimeSpan.FromSeconds(1);
}
}));
});
return services;
}

View File

@@ -21,7 +21,7 @@ public sealed record AdvisoryGuardrailResult(
public static AdvisoryGuardrailResult Allowed(string sanitizedPrompt, ImmutableDictionary<string, string>? metadata = null)
=> new(false, sanitizedPrompt, ImmutableArray<AdvisoryGuardrailViolation>.Empty, metadata ?? ImmutableDictionary<string, string>.Empty);
public static AdvisoryGuardrailResult Blocked(string sanitizedPrompt, IEnumerable<AdvisoryGuardrailViolation> violations, ImmutableDictionary<string, string>? metadata = null)
public static AdvisoryGuardrailResult Reject(string sanitizedPrompt, IEnumerable<AdvisoryGuardrailViolation> violations, ImmutableDictionary<string, string>? metadata = null)
=> new(true, sanitizedPrompt, violations.ToImmutableArray(), metadata ?? ImmutableDictionary<string, string>.Empty);
}
@@ -143,7 +143,7 @@ internal sealed class AdvisoryGuardrailPipeline : IAdvisoryGuardrailPipeline
if (blocked)
{
_logger?.LogWarning("Guardrail blocked prompt for cache key {CacheKey}", prompt.CacheKey);
return Task.FromResult(AdvisoryGuardrailResult.Blocked(sanitized, violations, metadata));
return Task.FromResult(AdvisoryGuardrailResult.Reject(sanitized, violations, metadata));
}
return Task.FromResult(AdvisoryGuardrailResult.Allowed(sanitized, metadata));

View File

@@ -1,27 +0,0 @@
using System;
using System.Collections.Generic;
namespace StellaOps.AdvisoryAI.Orchestration;
/// <summary>
/// Queue payload sent to workers to execute a pipeline plan.
/// </summary>
public sealed class AdvisoryPipelineExecutionMessage
{
public AdvisoryPipelineExecutionMessage(
string planCacheKey,
AdvisoryTaskRequest request,
IReadOnlyDictionary<string, string> planMetadata)
{
ArgumentException.ThrowIfNullOrWhiteSpace(planCacheKey);
PlanCacheKey = planCacheKey;
Request = request ?? throw new ArgumentNullException(nameof(request));
PlanMetadata = planMetadata ?? throw new ArgumentNullException(nameof(planMetadata));
}
public string PlanCacheKey { get; }
public AdvisoryTaskRequest Request { get; }
public IReadOnlyDictionary<string, string> PlanMetadata { get; }
}

View File

@@ -1,5 +1,7 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
@@ -118,8 +120,9 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
.RetrieveAsync(sbomRequest, cancellationToken)
.ConfigureAwait(false);
var analysis = _toolset.AnalyzeDependencies(context);
return (context, analysis);
var sanitizedContext = SanitizeContext(context, configuration);
var analysis = _toolset.AnalyzeDependencies(sanitizedContext);
return (sanitizedContext, analysis);
}
private static ImmutableDictionary<string, string> BuildMetadata(
@@ -133,7 +136,7 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
builder["task_type"] = request.TaskType.ToString();
builder["advisory_key"] = request.AdvisoryKey;
builder["profile"] = request.Profile;
builder["structured_chunk_count"] = structured.Chunks.Count.ToString(CultureInfo.InvariantCulture);
builder["structured_chunk_count"] = structured.Chunks.Count().ToString(CultureInfo.InvariantCulture);
builder["vector_query_count"] = vectors.Length.ToString(CultureInfo.InvariantCulture);
builder["vector_match_count"] = vectors.Sum(result => result.Matches.Length).ToString(CultureInfo.InvariantCulture);
builder["includes_sbom"] = (sbom is not null).ToString();
@@ -147,8 +150,8 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
if (sbom is not null)
{
builder["sbom_version_count"] = sbom.VersionTimeline.Count.ToString(CultureInfo.InvariantCulture);
builder["sbom_dependency_path_count"] = sbom.DependencyPaths.Count.ToString(CultureInfo.InvariantCulture);
builder["sbom_version_count"] = sbom.VersionTimeline.Length.ToString(CultureInfo.InvariantCulture);
builder["sbom_dependency_path_count"] = sbom.DependencyPaths.Length.ToString(CultureInfo.InvariantCulture);
if (!sbom.EnvironmentFlags.IsEmpty)
{
@@ -197,6 +200,34 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
return builder.ToImmutable();
}
private static SbomContextResult SanitizeContext(
SbomContextResult context,
AdvisoryTaskConfiguration configuration)
{
if ((configuration.IncludeEnvironmentFlags || context.EnvironmentFlags.IsEmpty)
&& (configuration.IncludeBlastRadius || context.BlastRadius is null))
{
return context;
}
var environmentFlags = configuration.IncludeEnvironmentFlags
? context.EnvironmentFlags
: ImmutableDictionary<string, string>.Empty;
var blastRadius = configuration.IncludeBlastRadius
? context.BlastRadius
: null;
return SbomContextResult.Create(
context.ArtifactId,
context.Purl,
context.VersionTimeline,
context.DependencyPaths,
environmentFlags,
blastRadius,
context.Metadata);
}
private static string ComputeCacheKey(
AdvisoryTaskRequest request,
AdvisoryRetrievalResult structured,
@@ -242,8 +273,8 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
if (sbom is not null)
{
builder.Append("|sbom:timeline=").Append(sbom.VersionTimeline.Count);
builder.Append("|sbom:paths=").Append(sbom.DependencyPaths.Count);
builder.Append("|sbom:timeline=").Append(sbom.VersionTimeline.Length);
builder.Append("|sbom:paths=").Append(sbom.DependencyPaths.Length);
foreach (var entry in sbom.VersionTimeline
.OrderBy(e => e.Version, StringComparer.Ordinal)
.ThenBy(e => e.FirstObserved.ToUnixTimeMilliseconds())

View File

@@ -72,8 +72,8 @@ public sealed class AdvisoryPipelinePlanResponse
{
sbomSummary = new PipelineSbomSummary(
plan.SbomContext.ArtifactId,
plan.SbomContext.VersionTimeline.Count,
plan.SbomContext.DependencyPaths.Count,
plan.SbomContext.VersionTimeline.Length,
plan.SbomContext.DependencyPaths.Length,
plan.DependencyAnalysis?.Nodes.Length ?? 0);
}

View File

@@ -1,6 +1,5 @@
using System.Collections.Immutable;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Documents;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Documents;
using StellaOps.AdvisoryAI.Tools;

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text;
using System.Text.Encodings.Web;
@@ -56,18 +57,18 @@ internal sealed class AdvisoryPromptAssembler : IAdvisoryPromptAssembler
var metadata = OrderMetadata(plan.Metadata);
var payload = new PromptPayload(
task: plan.Request.TaskType.ToString(),
advisoryKey: plan.Request.AdvisoryKey,
profile: plan.Request.Profile,
policyVersion: plan.Request.PolicyVersion,
instructions: ResolveInstruction(plan.Request.TaskType),
structured: structured.Select(chunk => chunk.Payload).ToImmutableArray(),
vectors: vectors,
sbom: sbom,
dependency: dependency,
metadata: metadata,
budget: new PromptBudget(plan.Budget.PromptTokens, plan.Budget.CompletionTokens),
policyContext: BuildPolicyContext(plan.Request));
Task: plan.Request.TaskType.ToString(),
AdvisoryKey: plan.Request.AdvisoryKey,
Profile: plan.Request.Profile,
PolicyVersion: plan.Request.PolicyVersion,
Instructions: ResolveInstruction(plan.Request.TaskType),
Structured: structured.Select(chunk => chunk.Payload).ToImmutableArray(),
Vectors: vectors,
Sbom: sbom,
Dependency: dependency,
Metadata: ToSortedDictionary(metadata),
Budget: new PromptBudget(plan.Budget.PromptTokens, plan.Budget.CompletionTokens),
PolicyContext: ToSortedDictionary(BuildPolicyContext(plan.Request)));
var promptJson = JsonSerializer.Serialize(payload, SerializerOptions);
@@ -114,6 +115,16 @@ internal sealed class AdvisoryPromptAssembler : IAdvisoryPromptAssembler
return ordered;
}
private static IReadOnlyDictionary<string, string> ToSortedDictionary(IReadOnlyDictionary<string, string> metadata)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableSortedDictionary.Create<string, string>(StringComparer.Ordinal);
}
return ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, metadata);
}
private static ImmutableArray<AdvisoryPromptCitation> BuildCitations(
ImmutableArray<PromptStructuredChunk> structured)
{
@@ -180,10 +191,10 @@ internal sealed class AdvisoryPromptAssembler : IAdvisoryPromptAssembler
.ToImmutableArray(),
path.IsRuntime,
path.Source,
OrderMetadata(path.Metadata)))
ToSortedDictionary(OrderMetadata(path.Metadata))))
.ToImmutableArray();
var environmentFlags = OrderMetadata(result.EnvironmentFlags);
var environmentFlags = ToSortedDictionary(OrderMetadata(result.EnvironmentFlags));
PromptSbomBlastRadius? blastRadius = null;
if (result.BlastRadius is not null)
@@ -193,7 +204,7 @@ internal sealed class AdvisoryPromptAssembler : IAdvisoryPromptAssembler
result.BlastRadius.ImpactedWorkloads,
result.BlastRadius.ImpactedNamespaces,
result.BlastRadius.ImpactedPercentage,
OrderMetadata(result.BlastRadius.Metadata));
ToSortedDictionary(OrderMetadata(result.BlastRadius.Metadata)));
}
return new PromptSbomContext(
@@ -203,7 +214,7 @@ internal sealed class AdvisoryPromptAssembler : IAdvisoryPromptAssembler
dependencyPaths,
environmentFlags,
blastRadius,
OrderMetadata(result.Metadata));
ToSortedDictionary(OrderMetadata(result.Metadata)));
}
private static PromptDependencySummary? BuildDependency(DependencyAnalysisResult? analysis)
@@ -225,7 +236,7 @@ internal sealed class AdvisoryPromptAssembler : IAdvisoryPromptAssembler
return new PromptDependencySummary(
analysis.ArtifactId,
nodes,
OrderMetadata(analysis.Metadata));
ToSortedDictionary(OrderMetadata(analysis.Metadata)));
}
private static ImmutableDictionary<string, string> BuildPolicyContext(AdvisoryTaskRequest request)
@@ -297,9 +308,9 @@ internal sealed class AdvisoryPromptAssembler : IAdvisoryPromptAssembler
ImmutableArray<PromptVectorQuery> Vectors,
PromptSbomContext? Sbom,
PromptDependencySummary? Dependency,
ImmutableDictionary<string, string> Metadata,
IReadOnlyDictionary<string, string> Metadata,
PromptBudget Budget,
ImmutableDictionary<string, string> PolicyContext);
IReadOnlyDictionary<string, string> PolicyContext);
private sealed record PromptStructuredChunk(
int Index,
@@ -317,7 +328,7 @@ internal sealed class AdvisoryPromptAssembler : IAdvisoryPromptAssembler
Section,
ParagraphId,
Text,
Metadata);
ToSortedDictionary(Metadata));
}
private sealed record PromptStructuredChunkPayload(
@@ -327,7 +338,7 @@ internal sealed class AdvisoryPromptAssembler : IAdvisoryPromptAssembler
string Section,
string ParagraphId,
string Text,
ImmutableDictionary<string, string> Metadata);
IReadOnlyDictionary<string, string> Metadata);
private sealed record PromptVectorQuery(string Query, ImmutableArray<PromptVectorMatch> Matches);
@@ -338,9 +349,9 @@ internal sealed class AdvisoryPromptAssembler : IAdvisoryPromptAssembler
string? Purl,
ImmutableArray<PromptSbomVersion> VersionTimeline,
ImmutableArray<PromptSbomDependencyPath> DependencyPaths,
ImmutableDictionary<string, string> EnvironmentFlags,
IReadOnlyDictionary<string, string> EnvironmentFlags,
PromptSbomBlastRadius? BlastRadius,
ImmutableDictionary<string, string> Metadata);
IReadOnlyDictionary<string, string> Metadata);
private sealed record PromptSbomVersion(
string Version,
@@ -353,7 +364,7 @@ internal sealed class AdvisoryPromptAssembler : IAdvisoryPromptAssembler
ImmutableArray<PromptSbomNode> Nodes,
bool IsRuntime,
string? Source,
ImmutableDictionary<string, string> Metadata);
IReadOnlyDictionary<string, string> Metadata);
private sealed record PromptSbomNode(string Identifier, string? Version);
@@ -362,12 +373,12 @@ internal sealed class AdvisoryPromptAssembler : IAdvisoryPromptAssembler
int ImpactedWorkloads,
int ImpactedNamespaces,
double? ImpactedPercentage,
ImmutableDictionary<string, string> Metadata);
IReadOnlyDictionary<string, string> Metadata);
private sealed record PromptDependencySummary(
string ArtifactId,
ImmutableArray<PromptDependencyNode> Nodes,
ImmutableDictionary<string, string> Metadata);
IReadOnlyDictionary<string, string> Metadata);
private sealed record PromptDependencyNode(
string Identifier,

View File

@@ -0,0 +1,17 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.AdvisoryAI.Providers;
/// <summary>
/// Fallback SBOM context client that always returns <c>null</c>, used when the SBOM service is not configured.
/// </summary>
internal sealed class NullSbomContextClient : ISbomContextClient
{
public Task<SbomContextDocument?> GetContextAsync(SbomContextQuery query, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
return Task.FromResult<SbomContextDocument?>(null);
}
}

View File

@@ -6,6 +6,7 @@ using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
@@ -92,7 +93,8 @@ internal sealed class SbomContextHttpClient : ISbomContextClient
response.EnsureSuccessStatusCode();
}
var payload = await response.Content.ReadFromJsonAsync<SbomContextPayload>(SerializerOptions, cancellationToken)
var httpContent = response.Content ?? throw new InvalidOperationException("SBOM context response did not include content.");
var payload = await httpContent.ReadFromJsonAsync<SbomContextPayload>(SerializerOptions, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (payload is null)

View File

@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />

View File

@@ -8,8 +8,8 @@
| AIAI-31-004A | DOING (2025-11-04) | Advisory AI Guild, Platform Guild | AIAI-31-004, AIAI-31-002 | Wire `AdvisoryPipelineOrchestrator` into WebService/Worker, expose API/queue contracts, emit metrics, and stand up cache stub. | API returns plan metadata; worker executes queue message; metrics recorded; doc updated. |
| AIAI-31-004B | TODO | Advisory AI Guild, Security Guild | AIAI-31-004A, DOCS-AIAI-31-003, AUTH-AIAI-31-004 | Implement prompt assembler, guardrail plumbing, cache persistence, DSSE provenance; add golden outputs. | Deterministic outputs cached; guardrails enforced; tests cover prompt assembly + caching. |
| AIAI-31-004C | TODO | Advisory AI Guild, CLI Guild, Docs Guild | AIAI-31-004B, CLI-AIAI-31-003 | Deliver CLI `stella advise run <task>` command, renderers, documentation updates, and CLI golden tests. | CLI command produces deterministic output; docs published; smoke run recorded. |
| AIAI-31-005 | TODO | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. |
| AIAI-31-006 | TODO | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. |
| AIAI-31-005 | DOING (2025-11-03) | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. |
| AIAI-31-006 | DOING (2025-11-03) | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. |
| AIAI-31-007 | TODO | Advisory AI Guild, Observability Guild | AIAI-31-004..006 | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. | Telemetry live; dashboards approved; alerts configured. |
| AIAI-31-008 | TODO | Advisory AI Guild, DevOps Guild | AIAI-31-006..007 | Package inference on-prem container, remote inference toggle, Helm/Compose manifests, scaling guidance, offline kit instructions. | Deployment docs merged; smoke deploy executed; offline kit updated; feature flags documented. |
| AIAI-31-010 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement Concelier advisory raw document provider mapping CSAF/OSV payloads into structured chunks for retrieval. | Provider resolves content format, preserves metadata, and passes unit tests covering CSAF/OSV cases. |

View File

@@ -42,7 +42,7 @@ internal sealed class DeterministicToolset : IDeterministicToolset
{
ArgumentNullException.ThrowIfNull(context);
if (context.DependencyPaths.Count == 0)
if (context.DependencyPaths.Length == 0)
{
return DependencyAnalysisResult.Empty(context.ArtifactId);
}
@@ -106,7 +106,7 @@ internal sealed class DeterministicToolset : IDeterministicToolset
["unique_nodes"] = summaries.Length.ToString(CultureInfo.InvariantCulture),
};
return new DependencyAnalysisResult(context.ArtifactId, summaries, metadata);
return DependencyAnalysisResult.Create(context.ArtifactId, summaries, metadata);
}
private static string NormalizeScheme(string scheme)