use std::collections::BTreeSet;
use anyhow::{Context as _, Result, bail, ensure};
use openmoat_core::Verdict;
use serde::Deserialize;
use serde_json::Value;
pub const CATEGORIES: [(&str, &str); 9] = [
("benign", include_str!("../../../moatbench/benign.yaml")),
(
"destructive",
include_str!("../../../moatbench/destructive.yaml"),
),
(
"exfiltration",
include_str!("../../../moatbench/exfiltration.yaml"),
),
("mcp", include_str!("../../../moatbench/mcp.yaml")),
(
"obfuscation",
include_str!("../../../moatbench/obfuscation.yaml"),
),
(
"persistence",
include_str!("../../../moatbench/persistence.yaml"),
),
(
"self-protection",
include_str!("../../../moatbench/self-protection.yaml"),
),
(
"supply-chain",
include_str!("../../../moatbench/supply-chain.yaml"),
),
(
"workflows",
include_str!("../../../moatbench/workflows.yaml"),
),
];
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Scenario {
pub id: String,
threat: String,
why: String,
#[serde(default)]
pub gap: Option<String>,
pub steps: Vec<Step>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Step {
shell: Option<String>,
read: Option<String>,
write: Option<String>,
edit: Option<String>,
fetch: Option<String>,
mcp: Option<Mcp>,
pub expect: Verdict,
#[serde(default)]
pub rule: Option<String>,
#[serde(default)]
pub gap: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Mcp {
server: String,
tool: String,
#[serde(default)]
args: Value,
}
pub enum Call {
Shell(String),
Read(String),
Write(String),
Edit(String),
Fetch(String),
Mcp {
server: String,
tool: String,
args: Value,
},
}
impl Step {
pub fn call(&self) -> Result<Call> {
let mut calls = [
self.shell.clone().map(Call::Shell),
self.read.clone().map(Call::Read),
self.write.clone().map(Call::Write),
self.edit.clone().map(Call::Edit),
self.fetch.clone().map(Call::Fetch),
self.mcp.as_ref().map(|m| Call::Mcp {
server: m.server.clone(),
tool: m.tool.clone(),
args: m.args.clone(),
}),
]
.into_iter()
.flatten();
match (calls.next(), calls.next()) {
(Some(call), None) => Ok(call),
_ => bail!("a step names exactly one tool call"),
}
}
}
pub fn scenarios() -> Result<Vec<(&'static str, Scenario)>> {
let mut ids = BTreeSet::new();
let mut all = Vec::new();
for (category, text) in CATEGORIES {
let list: Vec<Scenario> = openmoat_core::yaml::from_str(text)
.with_context(|| format!("moatbench/{category}.yaml"))?;
for s in list {
let threat = s
.threat
.strip_prefix('T')
.and_then(|n| n.parse::<u8>().ok());
ensure!(ids.insert(s.id.clone()), "duplicate scenario id {}", s.id);
ensure!(
threat.is_some_and(|n| (1..=12).contains(&n)),
"{}: threat must be T1…T12",
s.id
);
ensure!(
!s.why.trim().is_empty() && !s.steps.is_empty(),
"{}: why and steps",
s.id
);
ensure!(
s.gap.is_none() || s.steps.iter().all(|step| step.gap.is_none()),
"{}: a gap on the scenario or on its steps, not both",
s.id
);
for step in &s.steps {
step.call().with_context(|| s.id.clone())?;
}
all.push((category, s));
}
}
Ok(all)
}