// // Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later. // // ----------------------------------------------------------------------------- // ProveCommandGroup.cs // Sprint: SPRINT_20260105_002_001_REPLAY // Task: RPL-015 - Create ProveCommandGroup.cs with command structure // Description: CLI command for generating replay proofs for image verdicts. // ----------------------------------------------------------------------------- using System.CommandLine; using System.Collections.Immutable; using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using StellaOps.Cli.Replay; using StellaOps.Replay.Core.Models; using StellaOps.Verdict; using Spectre.Console; namespace StellaOps.Cli.Commands; /// /// Command group for replay proof operations. /// Implements: stella prove --image sha256:... [--at timestamp] [--snapshot id] [--output format] /// public static class ProveCommandGroup { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// /// Build the prove command tree. /// public static Command BuildProveCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var imageOption = new Option("--image", "-i") { Description = "Image digest (sha256:...) to generate proof for", Required = true }; var atOption = new Option("--at", "-a") { Description = "Point-in-time for snapshot lookup (ISO 8601 format, e.g., 2026-01-05T10:00:00Z)" }; var snapshotOption = new Option("--snapshot", "-s") { Description = "Explicit snapshot ID to use instead of time lookup" }; var bundleOption = new Option("--bundle", "-b") { Description = "Path to local replay bundle directory (offline mode)" }; var outputOption = new Option("--output", "-o") { Description = "Output format: compact, json, full" }; outputOption.SetDefaultValue("compact"); outputOption.FromAmong("compact", "json", "full"); var proveCommand = new Command("prove", "Generate replay proof for an image verdict") { imageOption, atOption, snapshotOption, bundleOption, outputOption, verboseOption }; proveCommand.SetAction(async (parseResult, ct) => { var image = parseResult.GetValue(imageOption) ?? string.Empty; var at = parseResult.GetValue(atOption); var snapshot = parseResult.GetValue(snapshotOption); var bundle = parseResult.GetValue(bundleOption); var output = parseResult.GetValue(outputOption) ?? "compact"; var verbose = parseResult.GetValue(verboseOption); return await HandleProveAsync( services, image, at, snapshot, bundle, output, verbose, cancellationToken); }); return proveCommand; } private static async Task HandleProveAsync( IServiceProvider services, string imageDigest, string? atTimestamp, string? snapshotId, string? bundlePath, string outputFormat, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(ProveCommandGroup)); try { // Validate image digest format if (!imageDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) && !imageDigest.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase)) { AnsiConsole.MarkupLine("[red]Error:[/] Image digest must start with sha256: or sha512:"); return ProveExitCodes.InvalidInput; } if (verbose) { logger?.LogDebug("Generating replay proof for image: {ImageDigest}", imageDigest); } // Mode 1: Local bundle path specified (offline mode) if (!string.IsNullOrEmpty(bundlePath)) { return await HandleLocalBundleProveAsync( services, bundlePath, imageDigest, outputFormat, verbose, logger, ct); } // Mode 2: Resolve snapshot from timeline string resolvedSnapshotId; if (!string.IsNullOrEmpty(snapshotId)) { resolvedSnapshotId = snapshotId; if (verbose) { logger?.LogDebug("Using explicit snapshot ID: {SnapshotId}", snapshotId); } } else if (!string.IsNullOrEmpty(atTimestamp)) { // Parse timestamp if (!DateTimeOffset.TryParse(atTimestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var pointInTime)) { AnsiConsole.MarkupLine($"[red]Error:[/] Invalid timestamp format: {atTimestamp}"); AnsiConsole.MarkupLine("[yellow]Expected:[/] ISO 8601 format (e.g., 2026-01-05T10:00:00Z)"); return ProveExitCodes.InvalidInput; } // Query timeline for snapshot at timestamp var timelineAdapter = services.GetService(); if (timelineAdapter is null) { AnsiConsole.MarkupLine("[red]Error:[/] Timeline service not available."); AnsiConsole.MarkupLine("[yellow]Hint:[/] Use --bundle to specify a local bundle path for offline mode."); return ProveExitCodes.ServiceUnavailable; } if (verbose) { logger?.LogDebug("Querying timeline for snapshot at {Timestamp}", pointInTime); } var snapshotResult = await timelineAdapter.GetSnapshotAtAsync(imageDigest, pointInTime, ct); if (snapshotResult is null) { AnsiConsole.MarkupLine($"[red]Error:[/] No verdict snapshot found for image at {pointInTime:O}"); return ProveExitCodes.SnapshotNotFound; } resolvedSnapshotId = snapshotResult.SnapshotId; if (verbose) { logger?.LogDebug("Resolved snapshot ID: {SnapshotId}", resolvedSnapshotId); } } else { // Get latest snapshot for image var timelineAdapter = services.GetService(); if (timelineAdapter is null) { AnsiConsole.MarkupLine("[red]Error:[/] Timeline service not available."); AnsiConsole.MarkupLine("[yellow]Hint:[/] Use --bundle to specify a local bundle path for offline mode."); return ProveExitCodes.ServiceUnavailable; } var latestSnapshot = await timelineAdapter.GetLatestSnapshotAsync(imageDigest, ct); if (latestSnapshot is null) { AnsiConsole.MarkupLine($"[red]Error:[/] No verdict snapshots found for image: {imageDigest}"); return ProveExitCodes.SnapshotNotFound; } resolvedSnapshotId = latestSnapshot.SnapshotId; if (verbose) { logger?.LogDebug("Using latest snapshot ID: {SnapshotId}", resolvedSnapshotId); } } // Fetch bundle from CAS var bundleStore = services.GetService(); if (bundleStore is null) { AnsiConsole.MarkupLine("[red]Error:[/] Replay bundle store not available."); return ProveExitCodes.ServiceUnavailable; } if (verbose) { logger?.LogDebug("Fetching bundle for snapshot: {SnapshotId}", resolvedSnapshotId); } var bundleInfo = await bundleStore.GetBundleAsync(resolvedSnapshotId, ct); if (bundleInfo is null) { AnsiConsole.MarkupLine($"[red]Error:[/] Bundle not found for snapshot: {resolvedSnapshotId}"); return ProveExitCodes.BundleNotFound; } // Execute replay and generate proof return await ExecuteReplayAndOutputProofAsync( services, bundleInfo.BundlePath, imageDigest, resolvedSnapshotId, bundleInfo.PolicyVersion, outputFormat, verbose, logger, ct); } catch (OperationCanceledException) { AnsiConsole.MarkupLine("[yellow]Operation cancelled.[/]"); return ProveExitCodes.Cancelled; } catch (Exception ex) { logger?.LogError(ex, "Failed to generate replay proof"); AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); return ProveExitCodes.SystemError; } } private static async Task HandleLocalBundleProveAsync( IServiceProvider services, string bundlePath, string imageDigest, string outputFormat, bool verbose, ILogger? logger, CancellationToken ct) { bundlePath = Path.GetFullPath(bundlePath); if (!Directory.Exists(bundlePath)) { AnsiConsole.MarkupLine($"[red]Error:[/] Bundle directory not found: {bundlePath}"); return ProveExitCodes.FileNotFound; } // Load manifest to get policy version var manifestPath = Path.Combine(bundlePath, "manifest.json"); if (!File.Exists(manifestPath)) { AnsiConsole.MarkupLine($"[red]Error:[/] Bundle manifest not found: {manifestPath}"); return ProveExitCodes.FileNotFound; } var manifestJson = await File.ReadAllTextAsync(manifestPath, ct); var manifest = JsonSerializer.Deserialize(manifestJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (manifest is null) { AnsiConsole.MarkupLine("[red]Error:[/] Failed to parse bundle manifest."); return ProveExitCodes.InvalidBundle; } if (verbose) { logger?.LogDebug("Loaded local bundle: {BundleId}", manifest.BundleId); } return await ExecuteReplayAndOutputProofAsync( services, bundlePath, imageDigest, manifest.BundleId, manifest.Scan.PolicyDigest, outputFormat, verbose, logger, ct); } private static async Task ExecuteReplayAndOutputProofAsync( IServiceProvider services, string bundlePath, string imageDigest, string snapshotId, string policyVersion, string outputFormat, bool verbose, ILogger? logger, CancellationToken ct) { var stopwatch = System.Diagnostics.Stopwatch.StartNew(); // Load manifest var manifestPath = Path.Combine(bundlePath, "manifest.json"); var manifestJson = await File.ReadAllTextAsync(manifestPath, ct); var manifest = JsonSerializer.Deserialize(manifestJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidOperationException("Failed to deserialize bundle manifest"); // Create VerdictBuilder and execute replay var verdictBuilder = new VerdictBuilderService( Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance.CreateLogger(), signer: null); var sbomPath = Path.Combine(bundlePath, manifest.Inputs.Sbom.Path); var feedsPath = manifest.Inputs.Feeds is not null ? Path.Combine(bundlePath, manifest.Inputs.Feeds.Path) : null; var vexPath = manifest.Inputs.Vex is not null ? Path.Combine(bundlePath, manifest.Inputs.Vex.Path) : null; var policyPath = manifest.Inputs.Policy is not null ? Path.Combine(bundlePath, manifest.Inputs.Policy.Path) : null; var replayRequest = new VerdictReplayRequest { SbomPath = sbomPath, FeedsPath = feedsPath, VexPath = vexPath, PolicyPath = policyPath, ImageDigest = manifest.Scan.ImageDigest, PolicyDigest = manifest.Scan.PolicyDigest, FeedSnapshotDigest = manifest.Scan.FeedSnapshotDigest }; if (verbose) { logger?.LogDebug("Executing verdict replay..."); } var result = await verdictBuilder.ReplayFromBundleAsync(replayRequest, ct); stopwatch.Stop(); if (!result.Success) { AnsiConsole.MarkupLine($"[red]Error:[/] Replay failed: {result.Error}"); return ProveExitCodes.ReplayFailed; } // Compute bundle hash var bundleHash = await ComputeBundleHashAsync(bundlePath, ct); // Check if verdict matches expected var verdictMatches = manifest.ExpectedOutputs?.VerdictHash is not null && string.Equals(result.VerdictHash, manifest.ExpectedOutputs.VerdictHash, StringComparison.OrdinalIgnoreCase); // Generate ReplayProof var proof = ReplayProof.FromExecutionResult( bundleHash: bundleHash, policyVersion: policyVersion, verdictRoot: result.VerdictHash ?? "unknown", verdictMatches: verdictMatches, durationMs: stopwatch.ElapsedMilliseconds, replayedAt: DateTimeOffset.UtcNow, engineVersion: result.EngineVersion ?? "1.0.0", artifactDigest: imageDigest, signatureVerified: null, signatureKeyId: null, metadata: ImmutableDictionary.Empty .Add("snapshotId", snapshotId) .Add("bundleId", manifest.BundleId)); // Output proof based on format OutputProof(proof, outputFormat, verbose); return verdictMatches ? ProveExitCodes.Success : ProveExitCodes.VerdictMismatch; } private static async Task ComputeBundleHashAsync(string bundlePath, CancellationToken ct) { var files = Directory.GetFiles(bundlePath, "*", SearchOption.AllDirectories) .OrderBy(f => f, StringComparer.Ordinal) .ToArray(); if (files.Length == 0) { return "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; } using var hasher = System.Security.Cryptography.SHA256.Create(); foreach (var file in files) { var fileBytes = await File.ReadAllBytesAsync(file, ct); hasher.TransformBlock(fileBytes, 0, fileBytes.Length, null, 0); } hasher.TransformFinalBlock(Array.Empty(), 0, 0); return $"sha256:{Convert.ToHexString(hasher.Hash!).ToLowerInvariant()}"; } private static void OutputProof(ReplayProof proof, string outputFormat, bool verbose) { switch (outputFormat.ToLowerInvariant()) { case "compact": AnsiConsole.WriteLine(proof.ToCompactString()); break; case "json": var json = proof.ToCanonicalJson(); AnsiConsole.WriteLine(json); break; case "full": OutputFullProof(proof); break; default: AnsiConsole.WriteLine(proof.ToCompactString()); break; } } private static void OutputFullProof(ReplayProof proof) { var table = new Table().AddColumns("Field", "Value"); table.BorderColor(Color.Grey); table.AddRow("Bundle Hash", proof.BundleHash); table.AddRow("Policy Version", proof.PolicyVersion); table.AddRow("Verdict Root", proof.VerdictRoot); table.AddRow("Duration", $"{proof.DurationMs}ms"); var matchDisplay = proof.VerdictMatches ? "[green]Yes[/]" : "[red]No[/]"; table.AddRow("Verdict Matches", matchDisplay); table.AddRow("Engine Version", proof.EngineVersion); table.AddRow("Replayed At", proof.ReplayedAt.ToString("O", CultureInfo.InvariantCulture)); if (!string.IsNullOrEmpty(proof.ArtifactDigest)) { table.AddRow("Artifact Digest", proof.ArtifactDigest); } if (proof.SignatureVerified.HasValue) { var sigDisplay = proof.SignatureVerified.Value ? "[green]Yes[/]" : "[red]No[/]"; table.AddRow("Signature Verified", sigDisplay); } if (!string.IsNullOrEmpty(proof.SignatureKeyId)) { table.AddRow("Signature Key ID", proof.SignatureKeyId); } if (proof.Metadata is { Count: > 0 }) { foreach (var kvp in proof.Metadata.OrderBy(k => k.Key, StringComparer.Ordinal)) { table.AddRow($"[grey]meta:{kvp.Key}[/]", kvp.Value); } } AnsiConsole.Write(table); AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("[bold]Compact Proof:[/]"); AnsiConsole.WriteLine(proof.ToCompactString()); } } /// /// Exit codes for the prove command. /// internal static class ProveExitCodes { public const int Success = 0; public const int InvalidInput = 1; public const int SnapshotNotFound = 2; public const int BundleNotFound = 3; public const int ReplayFailed = 4; public const int VerdictMismatch = 5; public const int ServiceUnavailable = 6; public const int FileNotFound = 7; public const int InvalidBundle = 8; public const int SystemError = 99; public const int Cancelled = 130; } /// /// Adapter interface for timeline query operations in CLI context. /// RPL-016: Timeline query service adapter. /// public interface ITimelineQueryAdapter { /// /// Get the snapshot ID for an image at a specific point in time. /// Task GetSnapshotAtAsync(string imageDigest, DateTimeOffset pointInTime, CancellationToken ct); /// /// Get the latest snapshot for an image. /// Task GetLatestSnapshotAsync(string imageDigest, CancellationToken ct); } /// /// Snapshot information returned by timeline queries. /// public sealed record SnapshotInfo( string SnapshotId, string ImageDigest, DateTimeOffset CreatedAt, string PolicyVersion); /// /// Adapter interface for replay bundle store operations in CLI context. /// RPL-017: Replay bundle store adapter. /// public interface IReplayBundleStoreAdapter { /// /// Get bundle information and download path for a snapshot. /// Task GetBundleAsync(string snapshotId, CancellationToken ct); } /// /// Bundle information returned by the bundle store. /// public sealed record BundleInfo( string SnapshotId, string BundlePath, string BundleHash, string PolicyVersion, long SizeBytes);