From 1ccaa764df2febad0712221b36b9aa7058d4f0be Mon Sep 17 00:00:00 2001 From: Nikola Katsarov Date: Thu, 30 Apr 2026 10:14:16 +0300 Subject: [PATCH] feat(evidence): inspect shows step output content for plaintext bundles Extends `boruna evidence inspect` to read and display step output files from the outputs/ directory for non-encrypted bundles. Text mode shows truncated previews (500 chars); --json mode includes full parsed content under a "step_outputs" key. Co-Authored-By: Claude Sonnet 4.6 --- crates/llmvm-cli/src/main.rs | 57 ++++++++- .../tests/cli_evidence_inspect_outputs.rs | 120 ++++++++++++++++++ 2 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 crates/llmvm-cli/tests/cli_evidence_inspect_outputs.rs diff --git a/crates/llmvm-cli/src/main.rs b/crates/llmvm-cli/src/main.rs index a5d2df0..437ae29 100644 --- a/crates/llmvm-cli/src/main.rs +++ b/crates/llmvm-cli/src/main.rs @@ -4108,11 +4108,44 @@ fn run_evidence( if let Some(info) = &manifest.encryption { eprintln!( "note: bundle is encrypted (algorithm={}, kek_id={}); \ - pass --decrypt to print decrypted file contents", + pass --decrypt --bundle-encryption-key to view step outputs", info.algorithm, info.kek_id ); + None + } else { + // Plaintext bundle: read outputs/ directory directly. + let outputs_dir = dir.join("outputs"); + if outputs_dir.is_dir() { + let mut outputs = + std::collections::BTreeMap::::new(); + if let Ok(entries) = fs::read_dir(&outputs_dir) { + for entry in entries.flatten() { + let step_id = + entry.file_name().to_string_lossy().into_owned(); + let result_path = entry.path().join("result.json"); + if result_path.exists() { + if let Ok(content) = + fs::read_to_string(&result_path) + { + let val: serde_json::Value = + serde_json::from_str(&content) + .unwrap_or_else(|_| { + serde_json::json!({"raw": content}) + }); + outputs.insert(step_id, val); + } + } + } + } + if outputs.is_empty() { + None + } else { + Some(outputs) + } + } else { + None + } } - None }; if json { @@ -4158,9 +4191,23 @@ fn run_evidence( manifest.env_fingerprint.os, manifest.env_fingerprint.arch ); if let Some(outputs) = step_outputs { - println!("\n=== Step Outputs (decrypted) ==="); - for (key, val) in &outputs { - println!("{key}: {val}"); + if decrypt { + println!("\n=== Step Outputs (decrypted) ==="); + for (key, val) in &outputs { + println!("{key}: {val}"); + } + } else { + println!("\n=== Step Outputs ==="); + for (key, val) in &outputs { + let rendered = val.to_string(); + if rendered.is_empty() { + println!("[{key}] (empty)"); + } else if rendered.len() > 500 { + println!("[{key}] {}... (truncated)", &rendered[..500]); + } else { + println!("[{key}] {rendered}"); + } + } } } } diff --git a/crates/llmvm-cli/tests/cli_evidence_inspect_outputs.rs b/crates/llmvm-cli/tests/cli_evidence_inspect_outputs.rs new file mode 100644 index 0000000..76ff60b --- /dev/null +++ b/crates/llmvm-cli/tests/cli_evidence_inspect_outputs.rs @@ -0,0 +1,120 @@ +//! Integration test: `boruna evidence inspect` shows step output content +//! for plaintext (non-encrypted) bundles. + +use std::fs; +use std::process::Command; +use tempfile::tempdir; + +fn boruna_bin() -> &'static str { + env!("CARGO_BIN_EXE_boruna") +} + +/// Build a minimal plaintext bundle directory with: +/// manifest.json — minimal BundleManifest fields +/// outputs/step_1/result.json — {"value": 42} +fn build_plaintext_bundle(base: &std::path::Path) -> std::path::PathBuf { + let bundle_dir = base.join("run-plaintext-inspect"); + fs::create_dir_all(&bundle_dir).unwrap(); + + // Minimal manifest.json that BundleManifest::deserialize accepts. + let manifest = serde_json::json!({ + "run_id": "run-plaintext-inspect", + "workflow_name": "test-workflow", + "started_at": "2026-01-01T00:00:00Z", + "completed_at": "2026-01-01T00:00:01Z", + "bundle_hash": "aaaa", + "workflow_hash": "bbbb", + "policy_hash": "cccc", + "audit_log_hash": "dddd", + "file_checksums": {}, + "env_fingerprint": { + "boruna_version": "0.0.0-test", + "rust_version": "1.0.0", + "os": "linux", + "arch": "x86_64", + "hostname": "test-host" + } + }); + fs::write( + bundle_dir.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + + // Step output. + let step_out_dir = bundle_dir.join("outputs").join("step_1"); + fs::create_dir_all(&step_out_dir).unwrap(); + fs::write(step_out_dir.join("result.json"), r#"{"value": 42}"#).unwrap(); + + bundle_dir +} + +#[test] +fn inspect_shows_step_outputs_for_plaintext_bundle() { + let dir = tempdir().unwrap(); + let bundle_dir = build_plaintext_bundle(dir.path()); + + let output = Command::new(boruna_bin()) + .args(["evidence", "inspect", bundle_dir.to_str().unwrap()]) + .output() + .expect("inspect failed to spawn"); + + assert!( + output.status.success(), + "inspect should succeed; status {:?}, stderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + stdout.contains("step_1"), + "stdout should contain the step id 'step_1'; stdout was:\n{stdout}" + ); + assert!( + stdout.contains("42"), + "stdout should contain the value '42' from result.json; stdout was:\n{stdout}" + ); + assert!( + stdout.contains("Step Outputs"), + "stdout should contain the 'Step Outputs' section header; stdout was:\n{stdout}" + ); +} + +#[test] +fn inspect_json_includes_step_outputs_for_plaintext_bundle() { + let dir = tempdir().unwrap(); + let bundle_dir = build_plaintext_bundle(dir.path()); + + let output = Command::new(boruna_bin()) + .args(["evidence", "inspect", bundle_dir.to_str().unwrap(), "--json"]) + .output() + .expect("inspect --json failed to spawn"); + + assert!( + output.status.success(), + "inspect --json should succeed; status {:?}, stderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("stdout should be valid JSON"); + + assert!( + parsed.get("step_outputs").is_some(), + "JSON output should have 'step_outputs' key; got:\n{stdout}" + ); + let step_outputs = &parsed["step_outputs"]; + assert!( + step_outputs.get("step_1").is_some(), + "step_outputs should have 'step_1' key; got:\n{step_outputs}" + ); + assert_eq!( + step_outputs["step_1"]["value"], + serde_json::json!(42), + "step_1 value should be 42" + ); +}