Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/ProveCommandGroup.cs
2026-01-07 09:43:12 +02:00

571 lines
20 KiB
C#

// <copyright file="ProveCommandGroup.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Command group for replay proof operations.
/// Implements: stella prove --image sha256:... [--at timestamp] [--snapshot id] [--output format]
/// </summary>
public static class ProveCommandGroup
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Build the prove command tree.
/// </summary>
public static Command BuildProveCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var imageOption = new Option<string>("--image", "-i")
{
Description = "Image digest (sha256:...) to generate proof for",
Required = true
};
var atOption = new Option<string?>("--at", "-a")
{
Description = "Point-in-time for snapshot lookup (ISO 8601 format, e.g., 2026-01-05T10:00:00Z)"
};
var snapshotOption = new Option<string?>("--snapshot", "-s")
{
Description = "Explicit snapshot ID to use instead of time lookup"
};
var bundleOption = new Option<string?>("--bundle", "-b")
{
Description = "Path to local replay bundle directory (offline mode)"
};
var outputOption = new Option<string>("--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<int> HandleProveAsync(
IServiceProvider services,
string imageDigest,
string? atTimestamp,
string? snapshotId,
string? bundlePath,
string outputFormat,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
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<ITimelineQueryAdapter>();
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<ITimelineQueryAdapter>();
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<IReplayBundleStoreAdapter>();
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<int> 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<ReplayBundleManifest>(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<int> 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<ReplayBundleManifest>(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<VerdictBuilderService>(),
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<string, string>.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<string> 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<byte>(), 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());
}
}
/// <summary>
/// Exit codes for the prove command.
/// </summary>
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;
}
/// <summary>
/// Adapter interface for timeline query operations in CLI context.
/// RPL-016: Timeline query service adapter.
/// </summary>
public interface ITimelineQueryAdapter
{
/// <summary>
/// Get the snapshot ID for an image at a specific point in time.
/// </summary>
Task<SnapshotInfo?> GetSnapshotAtAsync(string imageDigest, DateTimeOffset pointInTime, CancellationToken ct);
/// <summary>
/// Get the latest snapshot for an image.
/// </summary>
Task<SnapshotInfo?> GetLatestSnapshotAsync(string imageDigest, CancellationToken ct);
}
/// <summary>
/// Snapshot information returned by timeline queries.
/// </summary>
public sealed record SnapshotInfo(
string SnapshotId,
string ImageDigest,
DateTimeOffset CreatedAt,
string PolicyVersion);
/// <summary>
/// Adapter interface for replay bundle store operations in CLI context.
/// RPL-017: Replay bundle store adapter.
/// </summary>
public interface IReplayBundleStoreAdapter
{
/// <summary>
/// Get bundle information and download path for a snapshot.
/// </summary>
Task<BundleInfo?> GetBundleAsync(string snapshotId, CancellationToken ct);
}
/// <summary>
/// Bundle information returned by the bundle store.
/// </summary>
public sealed record BundleInfo(
string SnapshotId,
string BundlePath,
string BundleHash,
string PolicyVersion,
long SizeBytes);