use std::io::Write;
use std::path::Path;
use oatf::enums::{AttackResult, CorrelationLogic, IndicatorResult, Tier};
use serde::Serialize;
use crate::engine::types::TerminationReason;
use crate::error::ExitCode;
#[derive(Debug, Serialize)]
pub struct VerdictOutput {
pub attack: AttackMetadata,
pub verdict: VerdictBlock,
pub execution_summary: ExecutionSummary,
}
impl VerdictOutput {
pub fn set_context_attribution(&mut self, provider: &str, model: &str) {
self.execution_summary.transport = Some("context".to_string());
self.execution_summary.context_provider = Some(provider.to_string());
self.execution_summary.context_model = Some(model.to_string());
}
}
#[derive(Debug, Serialize)]
pub struct AttackMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub classification: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
pub struct VerdictBlock {
pub result: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tier: Option<String>,
pub indicator_verdicts: Vec<IndicatorVerdictOutput>,
pub evaluation_summary: EvaluationSummaryOutput,
#[serde(skip_serializing_if = "Option::is_none")]
pub correlation: Option<CorrelationOutput>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CorrelationOutput {
pub logic: String,
}
#[derive(Debug, Serialize)]
pub struct IndicatorVerdictOutput {
pub id: String,
pub result: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct EvaluationSummaryOutput {
pub matched: i64,
pub not_matched: i64,
pub error: i64,
pub skipped: i64,
}
#[derive(Debug, Serialize)]
pub struct ActorStatus {
pub name: String,
pub status: String,
pub phases_completed: usize,
pub total_phases: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub terminal_phase: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ExecutionSummary {
pub actors: Vec<ActorStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grace_period_applied: Option<String>,
pub trace_messages: usize,
pub duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub transport: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_model: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub trace_truncated: bool,
}
#[must_use]
pub fn attack_result_to_string(result: &AttackResult) -> String {
match result {
AttackResult::Exploited => "exploited".to_string(),
AttackResult::NotExploited => "not_exploited".to_string(),
AttackResult::Partial => "partial".to_string(),
AttackResult::Error => "error".to_string(),
}
}
#[must_use]
pub fn indicator_result_to_string(result: &IndicatorResult) -> String {
match result {
IndicatorResult::Matched => "matched".to_string(),
IndicatorResult::NotMatched => "not_matched".to_string(),
IndicatorResult::Error => "error".to_string(),
IndicatorResult::Skipped => "skipped".to_string(),
}
}
#[must_use]
pub const fn tier_to_string(tier: &Tier) -> &'static str {
match tier {
Tier::Ingested => "ingested",
Tier::LocalAction => "local_action",
Tier::BoundaryBreach => "boundary_breach",
}
}
#[must_use]
pub fn termination_to_status(reason: &TerminationReason) -> String {
match reason {
TerminationReason::TerminalPhaseReached | TerminationReason::TransportClosed => {
"completed".to_string()
}
TerminationReason::Cancelled => "cancelled".to_string(),
TerminationReason::MaxSessionExpired => "timeout".to_string(),
}
}
#[must_use]
pub fn build_verdict_output(
attack: &oatf::Attack,
verdict: &oatf::AttackVerdict,
actors: Vec<ActorStatus>,
grace_period_applied: Option<std::time::Duration>,
trace_messages: usize,
duration_ms: u64,
trace_truncated: bool,
) -> VerdictOutput {
let attack_metadata = AttackMetadata {
id: attack.id.clone(),
name: attack.name.clone(),
version: attack.version,
severity: attack
.severity
.as_ref()
.and_then(|s| serde_json::to_value(s).ok()),
classification: attack
.classification
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
};
let indicator_verdicts: Vec<IndicatorVerdictOutput> = verdict
.indicator_verdicts
.iter()
.map(|iv| IndicatorVerdictOutput {
id: iv.indicator_id.clone(),
result: indicator_result_to_string(&iv.result),
evidence: iv.evidence.clone(),
})
.collect();
let correlation = attack.correlation.as_ref().map(|c| {
let logic = match c.logic {
Some(CorrelationLogic::All) => "all",
Some(CorrelationLogic::Any) | None => "any",
};
CorrelationOutput {
logic: logic.to_string(),
}
});
let max_tier = verdict
.max_tier
.as_ref()
.map(|t| tier_to_string(t).to_string());
let verdict_block = VerdictBlock {
result: attack_result_to_string(&verdict.result),
max_tier,
indicator_verdicts,
evaluation_summary: EvaluationSummaryOutput {
matched: verdict.evaluation_summary.matched,
not_matched: verdict.evaluation_summary.not_matched,
error: verdict.evaluation_summary.error,
skipped: verdict.evaluation_summary.skipped,
},
correlation,
timestamp: verdict.timestamp.clone(),
source: verdict.source.clone(),
};
let grace_str = grace_period_applied.map(|d| humantime::format_duration(d).to_string());
let execution_summary = ExecutionSummary {
actors,
grace_period_applied: grace_str,
trace_messages,
duration_ms,
transport: None,
context_provider: None,
context_model: None,
trace_truncated,
};
VerdictOutput {
attack: attack_metadata,
verdict: verdict_block,
execution_summary,
}
}
pub fn write_json_verdict(output: &VerdictOutput, path: &str) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(output).map_err(std::io::Error::other)?;
if path == "-" {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
handle.write_all(json.as_bytes())?;
handle.write_all(b"\n")?;
handle.flush()?;
} else {
let path = Path::new(path);
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, format!("{json}\n"))?;
}
Ok(())
}
pub fn write_trace_jsonl(
trace: &[crate::engine::trace::TraceEntry],
path: &str,
) -> std::io::Result<()> {
use std::io::Write;
let mut writer: Box<dyn Write> = if path == "-" {
Box::new(std::io::stdout().lock())
} else {
let path = Path::new(path);
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
std::fs::create_dir_all(parent)?;
}
Box::new(std::io::BufWriter::new(std::fs::File::create(path)?))
};
for entry in trace {
serde_json::to_writer(&mut writer, entry).map_err(std::io::Error::other)?;
writer.write_all(b"\n")?;
}
writer.flush()?;
Ok(())
}
pub fn print_human_summary(output: &VerdictOutput) {
let stderr = std::io::stderr();
let mut w = stderr.lock();
let attack_name = output.attack.name.as_deref().unwrap_or("(unnamed attack)");
let attack_id = output.attack.id.as_deref().unwrap_or("");
let _ = writeln!(w);
if attack_id.is_empty() {
let _ = writeln!(w, " {attack_name}");
} else {
let _ = writeln!(w, " {attack_id}: {attack_name}");
}
if let Some(ref severity) = output.attack.severity
&& let Some(level) = severity.get("level").and_then(serde_json::Value::as_str)
{
let level_upper = level.to_uppercase();
let confidence_part = severity
.get("confidence")
.and_then(serde_json::Value::as_u64)
.map_or_else(String::new, |c| format!(" (confidence: {c})"));
let _ = writeln!(w, " Severity: {level_upper}{confidence_part}");
}
let result_str = &output.verdict.result;
let symbol = match result_str.as_str() {
"exploited" => "EXPLOITED",
"not_exploited" => "NOT EXPLOITED",
"partial" => "PARTIAL",
"error" => "ERROR",
_ => result_str.as_str(),
};
let tier_suffix = if result_str == "not_exploited" || result_str == "error" {
String::new()
} else if let Some(ref tier) = output.verdict.max_tier {
format!(" ({tier})")
} else {
" (unclassified)".to_string()
};
let _ = writeln!(w);
let _ = writeln!(w, " Result: {symbol}{tier_suffix}");
if !output.verdict.indicator_verdicts.is_empty() {
let _ = writeln!(w);
let _ = writeln!(w, " Indicators:");
for iv in &output.verdict.indicator_verdicts {
let icon = match iv.result.as_str() {
"matched" => "+",
"not_matched" => "-",
"skipped" => "o",
"error" => "!",
_ => "?",
};
let _ = writeln!(w, " {icon} {} [{}]", iv.id, iv.result);
if let Some(ref evidence) = iv.evidence {
let _ = writeln!(w, " {evidence}");
}
}
}
let s = &output.verdict.evaluation_summary;
let _ = writeln!(w);
let _ = writeln!(
w,
" Summary: {} matched, {} not matched, {} errors, {} skipped",
s.matched, s.not_matched, s.error, s.skipped
);
if let Some(ref corr) = output.verdict.correlation {
let detail = match corr.logic.as_str() {
"any" => "1 match sufficient",
"all" => "all must match",
_ => "",
};
if detail.is_empty() {
let _ = writeln!(w, " Correlation: {}", corr.logic);
} else {
let _ = writeln!(w, " Correlation: {} ({detail})", corr.logic);
}
}
let duration_secs = output.execution_summary.duration_ms / 1000;
let duration_tenths = (output.execution_summary.duration_ms % 1000) / 100;
let _ = writeln!(w);
let _ = writeln!(
w,
" Execution: {} messages, {duration_secs}.{duration_tenths}s",
output.execution_summary.trace_messages,
);
for actor in &output.execution_summary.actors {
let icon = match actor.status.as_str() {
"completed" => "+",
"error" | "timeout" => "!",
"cancelled" => "x",
_ => "?",
};
let _ = writeln!(
w,
" {icon} {}: {}/{} phases",
actor.name, actor.phases_completed, actor.total_phases
);
if let Some(ref err) = actor.error {
let _ = writeln!(w, " {err}");
}
}
let _ = writeln!(w);
}
#[must_use]
pub const fn verdict_exit_code(result: &AttackResult, max_tier: Option<&Tier>) -> i32 {
match result {
AttackResult::NotExploited => ExitCode::NOT_EXPLOITED,
AttackResult::Error => ExitCode::ERROR,
AttackResult::Exploited | AttackResult::Partial => match max_tier {
Some(Tier::BoundaryBreach) => ExitCode::EXPLOITED_BOUNDARY_BREACH,
Some(Tier::LocalAction) => ExitCode::EXPLOITED_LOCAL_ACTION,
Some(Tier::Ingested) => ExitCode::EXPLOITED,
None => {
match result {
AttackResult::Partial => ExitCode::PARTIAL,
_ => ExitCode::EXPLOITED,
}
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_attack(id: &str) -> oatf::Attack {
oatf::Attack {
id: Some(id.to_string()),
name: None,
version: None,
status: None,
created: None,
modified: None,
author: None,
description: None,
grace_period: None,
severity: None,
impact: None,
classification: None,
references: None,
execution: oatf::Execution {
mode: None,
state: None,
phases: None,
actors: None,
extensions: indexmap::IndexMap::new(),
},
indicators: Some(vec![]),
correlation: None,
extensions: indexmap::IndexMap::new(),
}
}
fn make_test_verdict(id: &str) -> oatf::AttackVerdict {
oatf::AttackVerdict {
attack_id: Some(id.to_string()),
result: oatf::enums::AttackResult::NotExploited,
max_tier: None,
indicator_verdicts: vec![],
evaluation_summary: oatf::EvaluationSummary {
matched: 0,
not_matched: 0,
error: 0,
skipped: 0,
},
timestamp: None,
source: None,
}
}
fn make_verdict_output(result: &str) -> VerdictOutput {
VerdictOutput {
attack: AttackMetadata {
id: Some("TJ-TEST-001".to_string()),
name: Some("Test Attack".to_string()),
version: Some(1),
severity: None,
classification: None,
},
verdict: VerdictBlock {
result: result.to_string(),
max_tier: None,
indicator_verdicts: vec![
IndicatorVerdictOutput {
id: "ind-1".to_string(),
result: "matched".to_string(),
evidence: Some("found malicious content".to_string()),
},
IndicatorVerdictOutput {
id: "ind-2".to_string(),
result: "skipped".to_string(),
evidence: Some("No inference engine configured".to_string()),
},
],
evaluation_summary: EvaluationSummaryOutput {
matched: 1,
not_matched: 0,
error: 0,
skipped: 1,
},
correlation: Some(CorrelationOutput {
logic: "any".to_string(),
}),
timestamp: Some("2026-02-26T00:00:00Z".to_string()),
source: Some("thoughtjack/0.5.0".to_string()),
},
execution_summary: ExecutionSummary {
actors: vec![ActorStatus {
name: "mcp_poison".to_string(),
status: "completed".to_string(),
phases_completed: 2,
total_phases: 2,
terminal_phase: Some("exploit".to_string()),
error: None,
}],
grace_period_applied: Some("30s".to_string()),
trace_messages: 12,
duration_ms: 4520,
transport: None,
context_provider: None,
context_model: None,
trace_truncated: false,
},
}
}
#[test]
fn exit_code_not_exploited() {
assert_eq!(verdict_exit_code(&AttackResult::NotExploited, None), 0);
}
#[test]
fn exit_code_exploited_no_tier() {
assert_eq!(verdict_exit_code(&AttackResult::Exploited, None), 1);
}
#[test]
fn exit_code_exploited_ingested() {
assert_eq!(
verdict_exit_code(&AttackResult::Exploited, Some(&Tier::Ingested)),
1
);
}
#[test]
fn exit_code_exploited_local_action() {
assert_eq!(
verdict_exit_code(&AttackResult::Exploited, Some(&Tier::LocalAction)),
2
);
}
#[test]
fn exit_code_exploited_boundary_breach() {
assert_eq!(
verdict_exit_code(&AttackResult::Exploited, Some(&Tier::BoundaryBreach)),
3
);
}
#[test]
fn exit_code_partial_no_tier() {
assert_eq!(verdict_exit_code(&AttackResult::Partial, None), 4);
}
#[test]
fn exit_code_partial_with_tier_uses_tier() {
assert_eq!(
verdict_exit_code(&AttackResult::Partial, Some(&Tier::BoundaryBreach)),
3
);
assert_eq!(
verdict_exit_code(&AttackResult::Partial, Some(&Tier::LocalAction)),
2
);
assert_eq!(
verdict_exit_code(&AttackResult::Partial, Some(&Tier::Ingested)),
1
);
}
#[test]
fn exit_code_error() {
assert_eq!(verdict_exit_code(&AttackResult::Error, None), 5);
}
#[test]
fn exit_code_error_ignores_tier() {
assert_eq!(
verdict_exit_code(&AttackResult::Error, Some(&Tier::BoundaryBreach)),
5
);
}
#[test]
fn attack_result_strings() {
assert_eq!(
attack_result_to_string(&AttackResult::Exploited),
"exploited"
);
assert_eq!(
attack_result_to_string(&AttackResult::NotExploited),
"not_exploited"
);
assert_eq!(attack_result_to_string(&AttackResult::Partial), "partial");
assert_eq!(attack_result_to_string(&AttackResult::Error), "error");
}
#[test]
fn indicator_result_strings() {
assert_eq!(
indicator_result_to_string(&IndicatorResult::Matched),
"matched"
);
assert_eq!(
indicator_result_to_string(&IndicatorResult::NotMatched),
"not_matched"
);
assert_eq!(indicator_result_to_string(&IndicatorResult::Error), "error");
assert_eq!(
indicator_result_to_string(&IndicatorResult::Skipped),
"skipped"
);
}
#[test]
fn termination_status_strings() {
assert_eq!(
termination_to_status(&TerminationReason::TerminalPhaseReached),
"completed"
);
assert_eq!(
termination_to_status(&TerminationReason::Cancelled),
"cancelled"
);
assert_eq!(
termination_to_status(&TerminationReason::MaxSessionExpired),
"timeout"
);
assert_eq!(
termination_to_status(&TerminationReason::TransportClosed),
"completed"
);
}
#[test]
fn json_output_serializes_correctly() {
let output = make_verdict_output("exploited");
let json = serde_json::to_string_pretty(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["attack"]["id"], "TJ-TEST-001");
assert_eq!(parsed["verdict"]["result"], "exploited");
assert_eq!(
parsed["verdict"]["indicator_verdicts"][0]["result"],
"matched"
);
assert_eq!(parsed["verdict"]["evaluation_summary"]["matched"], 1);
assert_eq!(parsed["execution_summary"]["trace_messages"], 12);
assert_eq!(parsed["execution_summary"]["duration_ms"], 4520);
}
#[test]
fn json_output_write_to_temp_file() {
let output = make_verdict_output("not_exploited");
let dir = std::env::temp_dir().join("thoughtjack_test_output");
let path = dir.join("verdict.json");
let _ = std::fs::remove_dir_all(&dir);
write_json_verdict(&output, path.to_str().unwrap()).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert_eq!(parsed["verdict"]["result"], "not_exploited");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ec_verdict_011_unicode_in_evidence() {
let output = VerdictOutput {
attack: AttackMetadata {
id: Some("TJ-TEST-001".to_string()),
name: Some("Unicode Test".to_string()),
version: Some(1),
severity: None,
classification: None,
},
verdict: VerdictBlock {
result: "exploited".to_string(),
max_tier: None,
indicator_verdicts: vec![IndicatorVerdictOutput {
id: "ind-1".to_string(),
result: "matched".to_string(),
evidence: Some(
"Tool name: \u{4e2d}\u{6587}\u{5de5}\u{5177} matched".to_string(),
),
}],
evaluation_summary: EvaluationSummaryOutput {
matched: 1,
not_matched: 0,
error: 0,
skipped: 0,
},
correlation: None,
timestamp: None,
source: None,
},
execution_summary: ExecutionSummary {
actors: vec![],
grace_period_applied: None,
trace_messages: 1,
duration_ms: 100,
transport: None,
context_provider: None,
context_model: None,
trace_truncated: false,
},
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\u{4e2d}\u{6587}\u{5de5}\u{5177}"));
}
#[test]
fn ec_verdict_012_permission_denied() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, b"x").unwrap();
let impossible = blocker.join("subdir").join("verdict.json");
let output = make_verdict_output("exploited");
let result = write_json_verdict(&output, impossible.to_str().unwrap());
assert!(result.is_err());
}
#[test]
fn human_summary_does_not_panic() {
let output = make_verdict_output("exploited");
print_human_summary(&output);
}
#[test]
fn human_summary_empty_indicators() {
let output = VerdictOutput {
attack: AttackMetadata {
id: None,
name: None,
version: None,
severity: None,
classification: None,
},
verdict: VerdictBlock {
result: "not_exploited".to_string(),
max_tier: None,
indicator_verdicts: vec![],
evaluation_summary: EvaluationSummaryOutput {
matched: 0,
not_matched: 0,
error: 0,
skipped: 0,
},
correlation: None,
timestamp: None,
source: None,
},
execution_summary: ExecutionSummary {
actors: vec![],
grace_period_applied: None,
trace_messages: 0,
duration_ms: 0,
transport: None,
context_provider: None,
context_model: None,
trace_truncated: false,
},
};
print_human_summary(&output);
}
#[test]
fn build_verdict_output_populates_fields() {
let attack = oatf::Attack {
id: Some("ATK-001".to_string()),
name: Some("Test".to_string()),
version: Some(1),
status: None,
created: None,
modified: None,
author: None,
description: None,
grace_period: None,
severity: None,
impact: None,
classification: None,
references: None,
execution: oatf::Execution {
mode: None,
state: None,
phases: None,
actors: None,
extensions: indexmap::IndexMap::new(),
},
indicators: None,
correlation: None,
extensions: indexmap::IndexMap::new(),
};
let verdict = oatf::AttackVerdict {
attack_id: Some("ATK-001".to_string()),
result: AttackResult::NotExploited,
max_tier: None,
indicator_verdicts: vec![],
evaluation_summary: oatf::EvaluationSummary {
matched: 0,
not_matched: 0,
error: 0,
skipped: 0,
},
timestamp: Some("2026-01-01T00:00:00Z".to_string()),
source: Some("thoughtjack/0.5.0".to_string()),
};
let actors = vec![ActorStatus {
name: "actor1".to_string(),
status: "completed".to_string(),
phases_completed: 2,
total_phases: 2,
terminal_phase: Some("terminal".to_string()),
error: None,
}];
let output = build_verdict_output(
&attack,
&verdict,
actors,
Some(std::time::Duration::from_secs(30)),
10,
1500,
false,
);
assert_eq!(output.attack.id.as_deref(), Some("ATK-001"));
assert_eq!(output.verdict.result, "not_exploited");
assert_eq!(output.execution_summary.trace_messages, 10);
assert_eq!(output.execution_summary.duration_ms, 1500);
assert!(output.execution_summary.grace_period_applied.is_some());
assert!(output.verdict.correlation.is_none());
}
#[test]
fn build_verdict_output_includes_correlation() {
use oatf::enums::CorrelationLogic;
let attack = oatf::Attack {
id: Some("ATK-002".to_string()),
name: Some("Correlated".to_string()),
version: Some(1),
status: None,
created: None,
modified: None,
author: None,
description: None,
grace_period: None,
severity: None,
impact: None,
classification: None,
references: None,
execution: oatf::Execution {
mode: None,
state: None,
phases: None,
actors: None,
extensions: indexmap::IndexMap::new(),
},
indicators: None,
correlation: Some(oatf::Correlation {
logic: Some(CorrelationLogic::All),
}),
extensions: indexmap::IndexMap::new(),
};
let verdict = oatf::AttackVerdict {
attack_id: Some("ATK-002".to_string()),
result: AttackResult::Exploited,
max_tier: None,
indicator_verdicts: vec![],
evaluation_summary: oatf::EvaluationSummary {
matched: 2,
not_matched: 0,
error: 0,
skipped: 0,
},
timestamp: None,
source: None,
};
let output = build_verdict_output(&attack, &verdict, vec![], None, 5, 100, false);
let corr = output.verdict.correlation.as_ref().unwrap();
assert_eq!(corr.logic, "all");
}
#[test]
fn build_verdict_output_with_max_tier() {
let attack = oatf::Attack {
id: Some("ATK-TIER".to_string()),
name: None,
version: None,
status: None,
created: None,
modified: None,
author: None,
description: None,
grace_period: None,
severity: None,
impact: None,
classification: None,
references: None,
execution: oatf::Execution {
mode: None,
state: None,
phases: None,
actors: None,
extensions: indexmap::IndexMap::new(),
},
indicators: None,
correlation: None,
extensions: indexmap::IndexMap::new(),
};
let verdict = oatf::AttackVerdict {
attack_id: Some("ATK-TIER".to_string()),
result: AttackResult::Exploited,
max_tier: Some(Tier::BoundaryBreach),
indicator_verdicts: vec![],
evaluation_summary: oatf::EvaluationSummary {
matched: 1,
not_matched: 0,
error: 0,
skipped: 0,
},
timestamp: None,
source: None,
};
let output = build_verdict_output(&attack, &verdict, vec![], None, 3, 500, false);
assert_eq!(
output.verdict.max_tier.as_deref(),
Some("boundary_breach"),
"max_tier should round-trip through build_verdict_output"
);
let json = serde_json::to_string(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["verdict"]["max_tier"], "boundary_breach");
}
#[test]
fn correlation_defaults_to_any() {
let attack = oatf::Attack {
id: None,
name: None,
version: None,
status: None,
created: None,
modified: None,
author: None,
description: None,
grace_period: None,
severity: None,
impact: None,
classification: None,
references: None,
execution: oatf::Execution {
mode: None,
state: None,
phases: None,
actors: None,
extensions: indexmap::IndexMap::new(),
},
indicators: None,
correlation: Some(oatf::Correlation { logic: None }),
extensions: indexmap::IndexMap::new(),
};
let verdict = oatf::AttackVerdict {
attack_id: None,
result: AttackResult::NotExploited,
max_tier: None,
indicator_verdicts: vec![],
evaluation_summary: oatf::EvaluationSummary {
matched: 0,
not_matched: 0,
error: 0,
skipped: 0,
},
timestamp: None,
source: None,
};
let output = build_verdict_output(&attack, &verdict, vec![], None, 0, 0, false);
let corr = output.verdict.correlation.as_ref().unwrap();
assert_eq!(corr.logic, "any");
}
#[test]
fn json_output_includes_correlation() {
let output = make_verdict_output("exploited");
let json = serde_json::to_string_pretty(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["verdict"]["correlation"]["logic"], "any");
}
#[test]
fn json_output_omits_correlation_when_none() {
let mut output = make_verdict_output("exploited");
output.verdict.correlation = None;
let json = serde_json::to_string_pretty(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["verdict"]["correlation"].is_null());
}
#[test]
fn human_summary_includes_severity() {
let mut output = make_verdict_output("exploited");
output.attack.severity = Some(serde_json::json!({"level": "high", "confidence": 85}));
print_human_summary(&output);
}
#[test]
fn human_summary_includes_correlation() {
let output = make_verdict_output("exploited");
print_human_summary(&output);
}
fn make_trace_entries(n: usize) -> Vec<crate::engine::trace::TraceEntry> {
use crate::engine::types::Direction;
(0..n)
.map(|i| crate::engine::trace::TraceEntry {
seq: i as u64,
timestamp: chrono::Utc::now(),
actor: "test_actor".to_string(),
phase: "phase_1".to_string(),
direction: if i % 2 == 0 {
Direction::Incoming
} else {
Direction::Outgoing
},
method: format!("method_{i}"),
content: serde_json::json!({"key": format!("value_{i}")}),
})
.collect()
}
#[test]
fn write_trace_jsonl_creates_file_with_correct_line_count() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trace.jsonl");
let entries = make_trace_entries(5);
write_trace_jsonl(&entries, path.to_str().unwrap()).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content.lines().count(), 5);
}
#[test]
fn write_trace_jsonl_each_line_is_valid_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trace.jsonl");
let entries = make_trace_entries(3);
write_trace_jsonl(&entries, path.to_str().unwrap()).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
for (i, line) in content.lines().enumerate() {
let parsed: serde_json::Value =
serde_json::from_str(line).expect("each line should be valid JSON");
assert_eq!(parsed["seq"], i as u64);
assert_eq!(parsed["actor"], "test_actor");
assert_eq!(parsed["method"], format!("method_{i}"));
assert!(parsed["content"]["key"].is_string());
}
}
#[test]
fn write_trace_jsonl_includes_full_content_payload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trace.jsonl");
let mut entries = make_trace_entries(1);
entries[0].content = serde_json::json!({
"name": "search",
"arguments": {"query": "test", "nested": {"deep": true}},
});
write_trace_jsonl(&entries, path.to_str().unwrap()).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(content.lines().next().unwrap()).unwrap();
assert_eq!(parsed["content"]["name"], "search");
assert_eq!(parsed["content"]["arguments"]["query"], "test");
assert_eq!(parsed["content"]["arguments"]["nested"]["deep"], true);
}
#[test]
fn write_trace_jsonl_creates_parent_directories() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("dir").join("trace.jsonl");
let entries = make_trace_entries(1);
write_trace_jsonl(&entries, path.to_str().unwrap()).unwrap();
assert!(path.exists());
}
#[test]
fn write_trace_jsonl_empty_trace_creates_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.jsonl");
write_trace_jsonl(&[], path.to_str().unwrap()).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.is_empty());
}
#[test]
fn set_context_attribution_populates_fields() {
let mut output = make_verdict_output("exploited");
assert!(output.execution_summary.transport.is_none());
output.set_context_attribution("openai", "gpt-4o");
assert_eq!(
output.execution_summary.transport.as_deref(),
Some("context")
);
assert_eq!(
output.execution_summary.context_provider.as_deref(),
Some("openai")
);
assert_eq!(
output.execution_summary.context_model.as_deref(),
Some("gpt-4o")
);
let json = serde_json::to_string(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["execution_summary"]["transport"], "context");
assert_eq!(parsed["execution_summary"]["context_provider"], "openai");
assert_eq!(parsed["execution_summary"]["context_model"], "gpt-4o");
}
#[test]
fn build_verdict_output_no_grace_period() {
let attack = oatf::Attack {
id: Some("ATK-003".to_string()),
name: None,
version: None,
status: None,
created: None,
modified: None,
author: None,
description: None,
grace_period: None,
severity: None,
impact: None,
classification: None,
references: None,
execution: oatf::Execution {
mode: None,
state: None,
phases: None,
actors: None,
extensions: indexmap::IndexMap::new(),
},
indicators: None,
correlation: None,
extensions: indexmap::IndexMap::new(),
};
let verdict = oatf::AttackVerdict {
attack_id: None,
result: AttackResult::NotExploited,
max_tier: None,
indicator_verdicts: vec![],
evaluation_summary: oatf::EvaluationSummary {
matched: 0,
not_matched: 0,
error: 0,
skipped: 0,
},
timestamp: None,
source: None,
};
let output = build_verdict_output(&attack, &verdict, vec![], None, 0, 0, false);
assert!(output.execution_summary.grace_period_applied.is_none());
}
#[test]
fn build_verdict_output_trace_truncated_serializes() {
let output = build_verdict_output(
&make_test_attack("ATK-TRUNC"),
&make_test_verdict("ATK-TRUNC"),
vec![],
None,
0,
0,
true,
);
assert!(output.execution_summary.trace_truncated);
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["execution_summary"]["trace_truncated"], true);
}
#[test]
fn build_verdict_output_trace_not_truncated_omitted() {
let output = build_verdict_output(
&make_test_attack("ATK-OK"),
&make_test_verdict("ATK-OK"),
vec![],
None,
0,
0,
false,
);
assert!(!output.execution_summary.trace_truncated);
let json = serde_json::to_value(&output).unwrap();
assert!(json["execution_summary"].get("trace_truncated").is_none());
}
#[test]
fn build_verdict_output_with_indicator_evidence() {
let attack = oatf::Attack {
id: Some("ATK-004".to_string()),
name: None,
version: None,
status: None,
created: None,
modified: None,
author: None,
description: None,
grace_period: None,
severity: None,
impact: None,
classification: None,
references: None,
execution: oatf::Execution {
mode: None,
state: None,
phases: None,
actors: None,
extensions: indexmap::IndexMap::new(),
},
indicators: None,
correlation: None,
extensions: indexmap::IndexMap::new(),
};
let verdict = oatf::AttackVerdict {
attack_id: Some("ATK-004".to_string()),
result: AttackResult::Exploited,
max_tier: None,
indicator_verdicts: vec![oatf::IndicatorVerdict {
indicator_id: "ind-1".to_string(),
result: IndicatorResult::Matched,
timestamp: None,
evidence: Some("found ssh key in arguments".to_string()),
source: None,
}],
evaluation_summary: oatf::EvaluationSummary {
matched: 1,
not_matched: 0,
error: 0,
skipped: 0,
},
timestamp: None,
source: None,
};
let output = build_verdict_output(&attack, &verdict, vec![], None, 5, 200, false);
assert_eq!(output.verdict.indicator_verdicts.len(), 1);
assert_eq!(output.verdict.indicator_verdicts[0].result, "matched");
assert_eq!(
output.verdict.indicator_verdicts[0].evidence.as_deref(),
Some("found ssh key in arguments")
);
}
#[test]
fn human_summary_error_status_actor() {
let mut output = make_verdict_output("error");
output.execution_summary.actors = vec![ActorStatus {
name: "broken_actor".to_string(),
status: "error".to_string(),
phases_completed: 0,
total_phases: 2,
terminal_phase: None,
error: Some("connection refused".to_string()),
}];
print_human_summary(&output);
}
#[test]
fn write_trace_jsonl_preserves_direction_field() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trace.jsonl");
let entries = make_trace_entries(2);
write_trace_jsonl(&entries, path.to_str().unwrap()).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(first["direction"], "incoming");
assert_eq!(second["direction"], "outgoing");
}
}