partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
using StellaOps.AdvisoryAI.Explanation;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// API request for Codex/Zastava companion explanation generation.
|
||||
/// </summary>
|
||||
public sealed record CompanionExplainRequest
|
||||
{
|
||||
[Required]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Scope { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string ScopeId { get; init; }
|
||||
|
||||
public string ExplanationType { get; init; } = "full";
|
||||
|
||||
[Required]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
public string? ComponentPurl { get; init; }
|
||||
|
||||
public bool PlainLanguage { get; init; }
|
||||
|
||||
public int MaxLength { get; init; }
|
||||
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
public IReadOnlyList<CompanionRuntimeSignalRequest> RuntimeSignals { get; init; } = Array.Empty<CompanionRuntimeSignalRequest>();
|
||||
|
||||
public CodexCompanionRequest ToDomain()
|
||||
{
|
||||
if (!Enum.TryParse<ExplanationType>(ExplanationType, ignoreCase: true, out var parsedType))
|
||||
{
|
||||
parsedType = StellaOps.AdvisoryAI.Explanation.ExplanationType.Full;
|
||||
}
|
||||
|
||||
return new CodexCompanionRequest
|
||||
{
|
||||
ExplanationRequest = new ExplanationRequest
|
||||
{
|
||||
FindingId = FindingId,
|
||||
ArtifactDigest = ArtifactDigest,
|
||||
Scope = Scope,
|
||||
ScopeId = ScopeId,
|
||||
ExplanationType = parsedType,
|
||||
VulnerabilityId = VulnerabilityId,
|
||||
ComponentPurl = ComponentPurl,
|
||||
PlainLanguage = PlainLanguage,
|
||||
MaxLength = MaxLength,
|
||||
CorrelationId = CorrelationId,
|
||||
},
|
||||
RuntimeSignals = RuntimeSignals.Select(static signal => new CompanionRuntimeSignal
|
||||
{
|
||||
Source = signal.Source,
|
||||
Signal = signal.Signal,
|
||||
Value = signal.Value,
|
||||
Path = signal.Path,
|
||||
Confidence = signal.Confidence,
|
||||
}).ToArray(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime signal request payload.
|
||||
/// </summary>
|
||||
public sealed record CompanionRuntimeSignalRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Source { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Signal { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Value { get; init; }
|
||||
|
||||
public string? Path { get; init; }
|
||||
|
||||
public double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API response for Codex/Zastava companion explanation generation.
|
||||
/// </summary>
|
||||
public sealed record CompanionExplainResponse
|
||||
{
|
||||
public required string CompanionId { get; init; }
|
||||
public required string CompanionHash { get; init; }
|
||||
public required ExplainResponse Explanation { get; init; }
|
||||
public required ExplainSummaryResponse CompanionSummary { get; init; }
|
||||
public required IReadOnlyList<CompanionRuntimeSignalResponse> RuntimeHighlights { get; init; }
|
||||
|
||||
public static CompanionExplainResponse FromDomain(CodexCompanionResponse response)
|
||||
{
|
||||
return new CompanionExplainResponse
|
||||
{
|
||||
CompanionId = response.CompanionId,
|
||||
CompanionHash = response.CompanionHash,
|
||||
Explanation = ExplainResponse.FromDomain(response.Explanation),
|
||||
CompanionSummary = new ExplainSummaryResponse
|
||||
{
|
||||
Line1 = response.CompanionSummary.Line1,
|
||||
Line2 = response.CompanionSummary.Line2,
|
||||
Line3 = response.CompanionSummary.Line3,
|
||||
},
|
||||
RuntimeHighlights = response.RuntimeHighlights.Select(static signal => new CompanionRuntimeSignalResponse
|
||||
{
|
||||
Source = signal.Source,
|
||||
Signal = signal.Signal,
|
||||
Value = signal.Value,
|
||||
Path = signal.Path,
|
||||
Confidence = signal.Confidence,
|
||||
}).ToArray(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime signal response payload.
|
||||
/// </summary>
|
||||
public sealed record CompanionRuntimeSignalResponse
|
||||
{
|
||||
public required string Source { get; init; }
|
||||
public required string Signal { get; init; }
|
||||
public required string Value { get; init; }
|
||||
public string? Path { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
}
|
||||
@@ -42,6 +42,7 @@ builder.Configuration
|
||||
|
||||
builder.Services.AddAdvisoryAiCore(builder.Configuration);
|
||||
builder.Services.AddAdvisoryChat(builder.Configuration);
|
||||
builder.Services.TryAddSingleton<ICodexCompanionService, CodexZastavaCompanionService>();
|
||||
|
||||
// Authorization service
|
||||
builder.Services.AddSingleton<StellaOps.AdvisoryAI.WebService.Services.IAuthorizationService, StellaOps.AdvisoryAI.WebService.Services.HeaderBasedAuthorizationService>();
|
||||
@@ -140,6 +141,9 @@ app.MapPost("/v1/advisory-ai/explain", HandleExplain)
|
||||
app.MapGet("/v1/advisory-ai/explain/{explanationId}/replay", HandleExplanationReplay)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapPost("/v1/advisory-ai/companion/explain", HandleCompanionExplain)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// Remediation endpoints (SPRINT_20251226_016_AI_remedy_autopilot)
|
||||
app.MapPost("/v1/advisory-ai/remediation/plan", HandleRemediationPlan)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
@@ -383,7 +387,9 @@ static bool EnsureExplainAuthorized(HttpContext context)
|
||||
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return allowed.Contains("advisory:run") || allowed.Contains("advisory:explain");
|
||||
return allowed.Contains("advisory:run")
|
||||
|| allowed.Contains("advisory:explain")
|
||||
|| allowed.Contains("advisory:companion");
|
||||
}
|
||||
|
||||
// ZASTAVA-13: POST /v1/advisory-ai/explain
|
||||
@@ -450,6 +456,40 @@ static async Task<IResult> HandleExplanationReplay(
|
||||
}
|
||||
}
|
||||
|
||||
// SPRINT_20260208_003: POST /v1/advisory-ai/companion/explain
|
||||
static async Task<IResult> HandleCompanionExplain(
|
||||
HttpContext httpContext,
|
||||
CompanionExplainRequest request,
|
||||
ICodexCompanionService companionService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.companion_explain", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.finding_id", request.FindingId);
|
||||
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
|
||||
activity?.SetTag("advisory.runtime_signal_count", request.RuntimeSignals.Count);
|
||||
|
||||
if (!EnsureExplainAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var domainRequest = request.ToDomain();
|
||||
var result = await companionService.GenerateAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
activity?.SetTag("advisory.companion_id", result.CompanionId);
|
||||
activity?.SetTag("advisory.companion_hash", result.CompanionHash);
|
||||
activity?.SetTag("advisory.explanation_id", result.Explanation.ExplanationId);
|
||||
|
||||
return Results.Ok(CompanionExplainResponse.FromDomain(result));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
static bool EnsureRemediationAuthorized(HttpContext context)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
||||
|
||||
@@ -6,3 +6,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
| SPRINT_20260208_003-WEB | DONE | Companion explain endpoint/contracts for Codex/Zastava flow. |
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Explanation;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime signal emitted by Zastava or compatible observers.
|
||||
/// </summary>
|
||||
public sealed record CompanionRuntimeSignal
|
||||
{
|
||||
public required string Source { get; init; }
|
||||
public required string Signal { get; init; }
|
||||
public required string Value { get; init; }
|
||||
public string? Path { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for Codex/Zastava companion explanation composition.
|
||||
/// </summary>
|
||||
public sealed record CodexCompanionRequest
|
||||
{
|
||||
public required ExplanationRequest ExplanationRequest { get; init; }
|
||||
public IReadOnlyList<CompanionRuntimeSignal> RuntimeSignals { get; init; } = Array.Empty<CompanionRuntimeSignal>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing base explanation plus deterministic runtime highlights.
|
||||
/// </summary>
|
||||
public sealed record CodexCompanionResponse
|
||||
{
|
||||
public required string CompanionId { get; init; }
|
||||
public required string CompanionHash { get; init; }
|
||||
public required ExplanationResult Explanation { get; init; }
|
||||
public required ExplanationSummary CompanionSummary { get; init; }
|
||||
public required IReadOnlyList<CompanionRuntimeSignal> RuntimeHighlights { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service that combines explanation output with Zastava runtime signals.
|
||||
/// </summary>
|
||||
public interface ICodexCompanionService
|
||||
{
|
||||
Task<CodexCompanionResponse> GenerateAsync(
|
||||
CodexCompanionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic implementation of the Codex/Zastava companion.
|
||||
/// </summary>
|
||||
public sealed class CodexZastavaCompanionService : ICodexCompanionService
|
||||
{
|
||||
private readonly IExplanationGenerator _explanationGenerator;
|
||||
|
||||
public CodexZastavaCompanionService(IExplanationGenerator explanationGenerator)
|
||||
{
|
||||
_explanationGenerator = explanationGenerator ?? throw new ArgumentNullException(nameof(explanationGenerator));
|
||||
}
|
||||
|
||||
public async Task<CodexCompanionResponse> GenerateAsync(
|
||||
CodexCompanionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.ExplanationRequest);
|
||||
|
||||
var explanation = await _explanationGenerator
|
||||
.GenerateAsync(request.ExplanationRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var highlights = NormalizeSignals(request.RuntimeSignals)
|
||||
.Take(5)
|
||||
.ToArray();
|
||||
|
||||
var companionSummary = BuildCompanionSummary(explanation.Summary, highlights);
|
||||
var companionHash = ComputeCompanionHash(explanation.OutputHash, highlights);
|
||||
|
||||
return new CodexCompanionResponse
|
||||
{
|
||||
CompanionId = $"companion:{companionHash}",
|
||||
CompanionHash = companionHash,
|
||||
Explanation = explanation,
|
||||
CompanionSummary = companionSummary,
|
||||
RuntimeHighlights = highlights,
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CompanionRuntimeSignal> NormalizeSignals(
|
||||
IReadOnlyList<CompanionRuntimeSignal> signals)
|
||||
{
|
||||
if (signals.Count == 0)
|
||||
{
|
||||
return Array.Empty<CompanionRuntimeSignal>();
|
||||
}
|
||||
|
||||
var deduplicated = new Dictionary<string, CompanionRuntimeSignal>(StringComparer.Ordinal);
|
||||
foreach (var signal in signals)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signal.Source) ||
|
||||
string.IsNullOrWhiteSpace(signal.Signal) ||
|
||||
string.IsNullOrWhiteSpace(signal.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = new CompanionRuntimeSignal
|
||||
{
|
||||
Source = signal.Source.Trim(),
|
||||
Signal = signal.Signal.Trim(),
|
||||
Value = signal.Value.Trim(),
|
||||
Path = string.IsNullOrWhiteSpace(signal.Path) ? null : signal.Path.Trim(),
|
||||
Confidence = Math.Clamp(signal.Confidence, 0, 1),
|
||||
};
|
||||
|
||||
var key = string.Join("|", normalized.Source, normalized.Signal, normalized.Value, normalized.Path ?? string.Empty);
|
||||
if (deduplicated.TryGetValue(key, out var existing))
|
||||
{
|
||||
deduplicated[key] = normalized.Confidence >= existing.Confidence
|
||||
? normalized
|
||||
: existing;
|
||||
}
|
||||
else
|
||||
{
|
||||
deduplicated[key] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return deduplicated.Values
|
||||
.OrderByDescending(static value => value.Confidence)
|
||||
.ThenBy(static value => value.Source, StringComparer.Ordinal)
|
||||
.ThenBy(static value => value.Signal, StringComparer.Ordinal)
|
||||
.ThenBy(static value => value.Value, StringComparer.Ordinal)
|
||||
.ThenBy(static value => value.Path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static ExplanationSummary BuildCompanionSummary(
|
||||
ExplanationSummary baseSummary,
|
||||
IReadOnlyList<CompanionRuntimeSignal> highlights)
|
||||
{
|
||||
var line2 = highlights.Count == 0
|
||||
? "No Zastava runtime signals were provided; verdict is based on static evidence."
|
||||
: $"Runtime signal {highlights[0].Source}/{highlights[0].Signal} indicates '{highlights[0].Value}'.";
|
||||
|
||||
return new ExplanationSummary
|
||||
{
|
||||
Line1 = $"Companion: {baseSummary.Line1}",
|
||||
Line2 = line2,
|
||||
Line3 = baseSummary.Line3,
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeCompanionHash(
|
||||
string explanationOutputHash,
|
||||
IReadOnlyList<CompanionRuntimeSignal> highlights)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(explanationOutputHash).Append('\n');
|
||||
|
||||
foreach (var highlight in highlights)
|
||||
{
|
||||
builder.Append(highlight.Source).Append('|')
|
||||
.Append(highlight.Signal).Append('|')
|
||||
.Append(highlight.Value).Append('|')
|
||||
.Append(highlight.Path ?? string.Empty).Append('|')
|
||||
.Append(highlight.Confidence.ToString("F4", System.Globalization.CultureInfo.InvariantCulture))
|
||||
.Append('\n');
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,6 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
|
||||
| AIAI-CHAT-AUDIT-0001 | DONE | Persist chat audit tables and logger. |
|
||||
| AUDIT-TESTGAP-ADVISORYAI-0001 | DONE | Added worker and unified plugin adapter tests. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
| SPRINT_20260208_003-CORE | DONE | Codex/Zastava companion core service for deterministic runtime-aware explanation composition. |
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Explanation;
|
||||
using StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Companion.Tests;
|
||||
|
||||
public sealed class CodexZastavaCompanionServiceTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GenerateAsync_IsDeterministicForPermutedSignals()
|
||||
{
|
||||
var explanation = CreateExplanationResult();
|
||||
var service = new CodexZastavaCompanionService(new StubExplanationGenerator(explanation));
|
||||
var explanationRequest = CreateExplanationRequest();
|
||||
|
||||
var requestA = new CodexCompanionRequest
|
||||
{
|
||||
ExplanationRequest = explanationRequest,
|
||||
RuntimeSignals =
|
||||
[
|
||||
new CompanionRuntimeSignal { Source = "zastava", Signal = "entrypoint", Value = "public-api", Path = "/api", Confidence = 0.60 },
|
||||
new CompanionRuntimeSignal { Source = "zastava", Signal = "reachable", Value = "true", Path = "/lib/a.cs", Confidence = 0.95 },
|
||||
new CompanionRuntimeSignal { Source = "zastava", Signal = "reachable", Value = "true", Path = "/lib/a.cs", Confidence = 0.10 },
|
||||
new CompanionRuntimeSignal { Source = "runtime", Signal = "exploit-path", Value = "direct", Path = "/proc", Confidence = 0.85 },
|
||||
],
|
||||
};
|
||||
|
||||
var requestB = new CodexCompanionRequest
|
||||
{
|
||||
ExplanationRequest = explanationRequest,
|
||||
RuntimeSignals =
|
||||
[
|
||||
new CompanionRuntimeSignal { Source = "runtime", Signal = "exploit-path", Value = "direct", Path = "/proc", Confidence = 0.85 },
|
||||
new CompanionRuntimeSignal { Source = "zastava", Signal = "reachable", Value = "true", Path = "/lib/a.cs", Confidence = 0.10 },
|
||||
new CompanionRuntimeSignal { Source = "zastava", Signal = "entrypoint", Value = "public-api", Path = "/api", Confidence = 0.60 },
|
||||
new CompanionRuntimeSignal { Source = "zastava", Signal = "reachable", Value = "true", Path = "/lib/a.cs", Confidence = 0.95 },
|
||||
],
|
||||
};
|
||||
|
||||
var resultA = await service.GenerateAsync(requestA);
|
||||
var resultB = await service.GenerateAsync(requestB);
|
||||
|
||||
resultA.CompanionHash.Should().Be(resultB.CompanionHash);
|
||||
resultA.RuntimeHighlights.Should().HaveCount(3);
|
||||
resultA.RuntimeHighlights[0].Signal.Should().Be("reachable");
|
||||
resultA.RuntimeHighlights[0].Confidence.Should().Be(0.95);
|
||||
resultA.CompanionSummary.Line1.Should().StartWith("Companion:");
|
||||
resultA.CompanionSummary.Line2.Should().Contain("zastava/reachable");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GenerateAsync_WithoutSignals_UsesStaticEvidenceSummary()
|
||||
{
|
||||
var explanation = CreateExplanationResult();
|
||||
var service = new CodexZastavaCompanionService(new StubExplanationGenerator(explanation));
|
||||
|
||||
var response = await service.GenerateAsync(new CodexCompanionRequest
|
||||
{
|
||||
ExplanationRequest = CreateExplanationRequest(),
|
||||
RuntimeSignals = [],
|
||||
});
|
||||
|
||||
response.RuntimeHighlights.Should().BeEmpty();
|
||||
response.CompanionSummary.Line2.Should().Contain("No Zastava runtime signals");
|
||||
response.CompanionId.Should().StartWith("companion:");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CompanionContracts_MapDomainRoundTrip()
|
||||
{
|
||||
var request = new CompanionExplainRequest
|
||||
{
|
||||
FindingId = "finding-1",
|
||||
ArtifactDigest = "sha256:aaa",
|
||||
Scope = "image",
|
||||
ScopeId = "img:v1",
|
||||
ExplanationType = "what",
|
||||
VulnerabilityId = "CVE-2026-0001",
|
||||
ComponentPurl = "pkg:npm/a@1.0.0",
|
||||
PlainLanguage = true,
|
||||
MaxLength = 120,
|
||||
CorrelationId = "corr-1",
|
||||
RuntimeSignals =
|
||||
[
|
||||
new CompanionRuntimeSignalRequest
|
||||
{
|
||||
Source = "zastava",
|
||||
Signal = "reachable",
|
||||
Value = "true",
|
||||
Path = "/app/main.cs",
|
||||
Confidence = 0.8,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var domainRequest = request.ToDomain();
|
||||
domainRequest.ExplanationRequest.ExplanationType.Should().Be(ExplanationType.What);
|
||||
domainRequest.RuntimeSignals.Should().HaveCount(1);
|
||||
|
||||
var domainResponse = new CodexCompanionResponse
|
||||
{
|
||||
CompanionId = "companion:abc",
|
||||
CompanionHash = "abc",
|
||||
Explanation = CreateExplanationResult(),
|
||||
CompanionSummary = new ExplanationSummary
|
||||
{
|
||||
Line1 = "Companion: line1",
|
||||
Line2 = "Companion: line2",
|
||||
Line3 = "Companion: line3",
|
||||
},
|
||||
RuntimeHighlights =
|
||||
[
|
||||
new CompanionRuntimeSignal
|
||||
{
|
||||
Source = "zastava",
|
||||
Signal = "reachable",
|
||||
Value = "true",
|
||||
Path = "/app/main.cs",
|
||||
Confidence = 0.8,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var apiResponse = CompanionExplainResponse.FromDomain(domainResponse);
|
||||
apiResponse.CompanionId.Should().Be("companion:abc");
|
||||
apiResponse.Explanation.ExplanationId.Should().Be(CreateExplanationResult().ExplanationId);
|
||||
apiResponse.RuntimeHighlights.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
private static ExplanationRequest CreateExplanationRequest()
|
||||
{
|
||||
return new ExplanationRequest
|
||||
{
|
||||
FindingId = "finding-1",
|
||||
ArtifactDigest = "sha256:aaa",
|
||||
Scope = "image",
|
||||
ScopeId = "img:v1",
|
||||
ExplanationType = ExplanationType.Full,
|
||||
VulnerabilityId = "CVE-2026-0001",
|
||||
ComponentPurl = "pkg:npm/a@1.0.0",
|
||||
PlainLanguage = false,
|
||||
MaxLength = 0,
|
||||
CorrelationId = "corr-1",
|
||||
};
|
||||
}
|
||||
|
||||
private static ExplanationResult CreateExplanationResult()
|
||||
{
|
||||
return new ExplanationResult
|
||||
{
|
||||
ExplanationId = "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
Content = "example explanation",
|
||||
Summary = new ExplanationSummary
|
||||
{
|
||||
Line1 = "Vulnerability is present.",
|
||||
Line2 = "It is reachable from runtime entrypoints.",
|
||||
Line3 = "Patch to the recommended fixed version.",
|
||||
},
|
||||
Citations = [],
|
||||
ConfidenceScore = 0.9,
|
||||
CitationRate = 1.0,
|
||||
Authority = ExplanationAuthority.EvidenceBacked,
|
||||
EvidenceRefs = ["ev-1"],
|
||||
ModelId = "model-x",
|
||||
PromptTemplateVersion = "explain-v1",
|
||||
InputHashes = ["hash-a", "hash-b", "hash-c"],
|
||||
GeneratedAt = "2026-02-08T00:00:00.0000000Z",
|
||||
OutputHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubExplanationGenerator : IExplanationGenerator
|
||||
{
|
||||
private readonly ExplanationResult _result;
|
||||
|
||||
public StubExplanationGenerator(ExplanationResult result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public Task<ExplanationResult> GenerateAsync(ExplanationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_result);
|
||||
}
|
||||
|
||||
public Task<ExplanationResult> ReplayAsync(string explanationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public Task<bool> ValidateAsync(ExplanationResult result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.Explanation;
|
||||
using StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Companion.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class CompanionExplainEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompanionExplain_WithoutScopes_ReturnsForbidden()
|
||||
{
|
||||
await using var factory = new WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program>();
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Client", "companion-tests");
|
||||
|
||||
var request = new CompanionExplainRequest
|
||||
{
|
||||
FindingId = "finding-1",
|
||||
ArtifactDigest = "sha256:aaa",
|
||||
Scope = "tenant",
|
||||
ScopeId = "tenant-a",
|
||||
VulnerabilityId = "CVE-2026-0001",
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/advisory-ai/companion/explain", request);
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompanionExplain_WithScope_MapsRequestAndReturnsCompanionResponse()
|
||||
{
|
||||
var stub = new CapturingCompanionService();
|
||||
await using var factory = CreateFactory(stub);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Client", "companion-tests");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory:companion");
|
||||
|
||||
var request = new CompanionExplainRequest
|
||||
{
|
||||
FindingId = "finding-1",
|
||||
ArtifactDigest = "sha256:aaa",
|
||||
Scope = "tenant",
|
||||
ScopeId = "tenant-a",
|
||||
ExplanationType = "what",
|
||||
VulnerabilityId = "CVE-2026-0001",
|
||||
RuntimeSignals =
|
||||
[
|
||||
new CompanionRuntimeSignalRequest
|
||||
{
|
||||
Source = "zastava",
|
||||
Signal = "reachable",
|
||||
Value = "true",
|
||||
Path = "/app/main.cs",
|
||||
Confidence = 0.9,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/advisory-ai/companion/explain", request);
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<CompanionExplainResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.CompanionId.Should().Be("companion:stub");
|
||||
payload.RuntimeHighlights.Should().ContainSingle();
|
||||
|
||||
stub.LastRequest.Should().NotBeNull();
|
||||
stub.LastRequest!.ExplanationRequest.Scope.Should().Be("tenant");
|
||||
stub.LastRequest.ExplanationRequest.ScopeId.Should().Be("tenant-a");
|
||||
stub.LastRequest.ExplanationRequest.ExplanationType.Should().Be(ExplanationType.What);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompanionExplain_WhenServiceRejectsRequest_ReturnsBadRequest()
|
||||
{
|
||||
await using var factory = CreateFactory(new ThrowingCompanionService());
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Client", "companion-tests");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory:companion");
|
||||
|
||||
var request = new CompanionExplainRequest
|
||||
{
|
||||
FindingId = "finding-1",
|
||||
ArtifactDigest = "sha256:aaa",
|
||||
Scope = "tenant",
|
||||
ScopeId = "tenant-a",
|
||||
VulnerabilityId = "CVE-2026-0001",
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/advisory-ai/companion/explain", request);
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
private static WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program> CreateFactory(ICodexCompanionService service)
|
||||
{
|
||||
return new WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton(service);
|
||||
services.AddSingleton<ICodexCompanionService>(service);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class CapturingCompanionService : ICodexCompanionService
|
||||
{
|
||||
public CodexCompanionRequest? LastRequest { get; private set; }
|
||||
|
||||
public Task<CodexCompanionResponse> GenerateAsync(CodexCompanionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastRequest = request;
|
||||
|
||||
return Task.FromResult(new CodexCompanionResponse
|
||||
{
|
||||
CompanionId = "companion:stub",
|
||||
CompanionHash = "stub",
|
||||
Explanation = new ExplanationResult
|
||||
{
|
||||
ExplanationId = "sha256:stub",
|
||||
Content = "stub explanation",
|
||||
Summary = new ExplanationSummary
|
||||
{
|
||||
Line1 = "line1",
|
||||
Line2 = "line2",
|
||||
Line3 = "line3",
|
||||
},
|
||||
Citations = [],
|
||||
ConfidenceScore = 1.0,
|
||||
CitationRate = 1.0,
|
||||
Authority = ExplanationAuthority.EvidenceBacked,
|
||||
EvidenceRefs = [],
|
||||
ModelId = "stub-model",
|
||||
PromptTemplateVersion = "stub-template",
|
||||
InputHashes = [],
|
||||
GeneratedAt = "2026-02-08T00:00:00.0000000Z",
|
||||
OutputHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
},
|
||||
CompanionSummary = new ExplanationSummary
|
||||
{
|
||||
Line1 = "Companion: line1",
|
||||
Line2 = "Companion: line2",
|
||||
Line3 = "Companion: line3",
|
||||
},
|
||||
RuntimeHighlights =
|
||||
[
|
||||
new CompanionRuntimeSignal
|
||||
{
|
||||
Source = "zastava",
|
||||
Signal = "reachable",
|
||||
Value = "true",
|
||||
Path = "/app/main.cs",
|
||||
Confidence = 0.9,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingCompanionService : ICodexCompanionService
|
||||
{
|
||||
public Task<CodexCompanionResponse> GenerateAsync(CodexCompanionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new InvalidOperationException("invalid companion request");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.WebService\StellaOps.AdvisoryAI.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -6,3 +6,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
| SPRINT_20260208_003-TESTS | DONE | Deterministic Codex/Zastava companion service, contract tests, and endpoint integration tests. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user