use std::collections::HashMap;
use crate::policy::{
document::{
ActionOnExceed, ActiveHours, ApprovalPolicy, BudgetPolicy, CredentialAction, DataPolicy, NetworkPolicy,
PolicyDocument, SchedulePolicy, ToolPolicy,
},
error::{ValidationError, ValidationWarning},
raw::{GovernancePolicyEnvelope, RawPolicyDocument},
scope::PolicyScope,
};
#[derive(Debug)]
pub struct PolicyValidatorOutput {
pub document: PolicyDocument,
pub warnings: Vec<ValidationWarning>,
}
pub struct PolicyValidator;
impl PolicyValidator {
pub fn from_yaml(yaml_str: &str) -> Result<PolicyValidatorOutput, Vec<ValidationError>> {
let (raw, metadata) = Self::parse_yaml(yaml_str)?;
let mut errors: Vec<ValidationError> = Vec::new();
let mut warnings: Vec<ValidationWarning> = Vec::new();
for key in raw.unknown.keys() {
warnings.push(ValidationWarning::unknown_key(key));
}
let network = Self::validate_network(raw.network, &mut errors, &mut warnings);
let schedule = Self::validate_schedule(raw.schedule, &mut errors, &mut warnings);
let budget = Self::validate_budget(raw.budget, &mut errors);
let data = Self::validate_data(raw.data, &mut errors);
let tools = Self::validate_tools(raw.tools, &mut errors, &mut warnings);
let capabilities = Self::validate_capabilities(raw.capabilities, &mut errors, &mut warnings);
let approval_policy = Self::validate_approval_policy(raw.approval, &mut errors, &mut warnings);
let approval_timeout_secs = match raw.approval_timeout_secs {
Some(0) => {
errors.push(ValidationError::new("approval_timeout_secs", "must be greater than 0"));
300
}
Some(v) => v,
None => 300,
};
if !errors.is_empty() {
return Err(errors);
}
let (meta_name, meta_version) = match metadata {
Some(m) => (m.name, m.version),
None => (None, None),
};
Ok(PolicyValidatorOutput {
document: PolicyDocument {
name: meta_name,
policy_version: meta_version,
version: raw.version,
scope: raw.scope.unwrap_or(PolicyScope::Global),
network,
schedule,
budget,
data,
approval_timeout_secs,
approval_policy,
tools,
capabilities,
},
warnings,
})
}
fn parse_yaml(
yaml_str: &str,
) -> Result<(RawPolicyDocument, Option<crate::policy::raw::RawMetadata>), Vec<ValidationError>> {
let make_parse_error = |e: serde_yaml::Error| {
let line = e.location().map(|l| l.line() as u32);
let mut err = ValidationError::new("(document)", format!("YAML parse error: {}", e));
if let Some(l) = line {
err = err.with_line(l);
}
vec![err]
};
if let Ok(envelope) = serde_yaml::from_str::<GovernancePolicyEnvelope>(yaml_str) {
if let Some(spec_value) = envelope.spec {
let raw: RawPolicyDocument = serde_yaml::from_value(spec_value).map_err(make_parse_error)?;
return Ok((raw, envelope.metadata));
}
}
let raw: RawPolicyDocument = serde_yaml::from_str(yaml_str).map_err(make_parse_error)?;
Ok((raw, None))
}
fn validate_network(
raw: Option<crate::policy::raw::RawNetworkPolicy>,
errors: &mut Vec<ValidationError>,
warnings: &mut Vec<ValidationWarning>,
) -> Option<NetworkPolicy> {
let raw = raw?;
for key in raw.unknown.keys() {
warnings.push(ValidationWarning::unknown_key(format!("network.{}", key)));
}
let allowlist = raw.allowlist.unwrap_or_default();
for (i, entry) in allowlist.iter().enumerate() {
if entry.trim().is_empty() {
errors.push(ValidationError::new(
format!("network.allowlist[{}]", i),
"allowlist entry must not be empty",
));
}
}
Some(NetworkPolicy { allowlist })
}
fn validate_schedule(
raw: Option<crate::policy::raw::RawSchedulePolicy>,
errors: &mut Vec<ValidationError>,
warnings: &mut Vec<ValidationWarning>,
) -> Option<SchedulePolicy> {
let raw = raw?;
for key in raw.unknown.keys() {
warnings.push(ValidationWarning::unknown_key(format!("schedule.{}", key)));
}
let active_hours = raw
.active_hours
.and_then(|ah| Self::validate_active_hours(ah, errors, warnings));
Some(SchedulePolicy { active_hours })
}
fn validate_active_hours(
raw: crate::policy::raw::RawActiveHours,
errors: &mut Vec<ValidationError>,
warnings: &mut Vec<ValidationWarning>,
) -> Option<ActiveHours> {
for key in raw.unknown.keys() {
warnings.push(ValidationWarning::unknown_key(format!("schedule.active_hours.{}", key)));
}
let start = match raw.start {
Some(s) => {
if !is_hhmm(&s) {
errors.push(ValidationError::new(
"schedule.active_hours.start",
"must be in HH:MM 24-hour format",
));
return None;
}
s
}
None => {
errors.push(ValidationError::new(
"schedule.active_hours.start",
"required when active_hours is present",
));
return None;
}
};
let end = match raw.end {
Some(e) => {
if !is_hhmm(&e) {
errors.push(ValidationError::new(
"schedule.active_hours.end",
"must be in HH:MM 24-hour format",
));
return None;
}
e
}
None => {
errors.push(ValidationError::new(
"schedule.active_hours.end",
"required when active_hours is present",
));
return None;
}
};
if start >= end {
errors.push(ValidationError::new(
"schedule.active_hours",
"start must be earlier than end",
));
return None;
}
let timezone = match raw.timezone {
Some(tz) => tz,
None => {
errors.push(ValidationError::new(
"schedule.active_hours.timezone",
"required when active_hours is present",
));
return None;
}
};
Some(ActiveHours { start, end, timezone })
}
fn validate_budget(
raw: Option<crate::policy::raw::RawBudgetPolicy>,
errors: &mut Vec<ValidationError>,
) -> Option<BudgetPolicy> {
let raw = raw?;
if let Some(limit) = raw.daily_limit_usd {
if limit <= 0.0 {
errors.push(ValidationError::new("budget.daily_limit_usd", "must be greater than 0"));
}
}
if let Some(limit) = raw.monthly_limit_usd {
if limit <= 0.0 {
errors.push(ValidationError::new(
"budget.monthly_limit_usd",
"must be greater than 0",
));
}
if let Some(daily) = raw.daily_limit_usd {
if limit < daily {
errors.push(ValidationError::new(
"budget.monthly_limit_usd",
"must be >= daily_limit_usd",
));
}
}
}
if let Some(limit) = raw.org_daily_limit_usd {
if limit <= 0.0 {
errors.push(ValidationError::new(
"budget.org_daily_limit_usd",
"must be greater than 0",
));
}
}
if let Some(limit) = raw.org_monthly_limit_usd {
if limit <= 0.0 {
errors.push(ValidationError::new(
"budget.org_monthly_limit_usd",
"must be greater than 0",
));
}
if let Some(daily) = raw.org_daily_limit_usd {
if limit < daily {
errors.push(ValidationError::new(
"budget.org_monthly_limit_usd",
"must be >= org_daily_limit_usd",
));
}
}
}
if let Some(tz_str) = &raw.timezone {
if tz_str.parse::<chrono_tz::Tz>().is_err() {
errors.push(ValidationError::new(
"budget.timezone",
format!("'{}' is not a valid IANA timezone name", tz_str),
));
}
}
let action_on_exceed = match raw.action_on_exceed.as_deref() {
Some("deny") | None => ActionOnExceed::Deny,
Some("suspend") => ActionOnExceed::Suspend,
Some(other) => {
errors.push(ValidationError::new(
"budget.action_on_exceed",
format!("must be 'deny' or 'suspend', got '{}'", other),
));
ActionOnExceed::Deny
}
};
let window = match raw.window.as_deref() {
None => None,
Some(s) => match humantime::parse_duration(s) {
Ok(d) if d.is_zero() => {
errors.push(ValidationError::new(
"budget.window",
"must be a positive duration (e.g. '5s', '30m', '1h')",
));
None
}
Ok(d) => Some(d),
Err(e) => {
errors.push(ValidationError::new(
"budget.window",
format!("'{s}' is not a valid humantime duration: {e}"),
));
None
}
},
};
Some(BudgetPolicy {
daily_limit_usd: raw.daily_limit_usd,
monthly_limit_usd: raw.monthly_limit_usd,
org_daily_limit_usd: raw.org_daily_limit_usd,
org_monthly_limit_usd: raw.org_monthly_limit_usd,
timezone: raw.timezone,
action_on_exceed,
window,
})
}
fn validate_data(
raw: Option<crate::policy::raw::RawDataPolicy>,
errors: &mut Vec<ValidationError>,
) -> Option<DataPolicy> {
let raw = raw?;
let patterns = raw.sensitive_patterns.unwrap_or_default();
for (i, pattern) in patterns.iter().enumerate() {
if regex::Regex::new(pattern).is_err() {
errors.push(ValidationError::new(
format!("data.sensitive_patterns[{}]", i),
format!("invalid regex: {}", pattern),
));
}
}
let credential_action = match raw.credential_action.as_deref() {
None | Some("redact_only") => CredentialAction::RedactOnly,
Some("block") => CredentialAction::Block,
Some("alert_only") => CredentialAction::AlertOnly,
Some(other) => {
errors.push(ValidationError::new(
"data.credential_action",
format!("must be 'block', 'redact_only', or 'alert_only', got '{}'", other),
));
CredentialAction::RedactOnly
}
};
Some(DataPolicy {
sensitive_patterns: patterns,
credential_action,
})
}
fn validate_capabilities(
raw: Option<crate::policy::raw::RawCapabilitySet>,
errors: &mut Vec<ValidationError>,
warnings: &mut Vec<ValidationWarning>,
) -> Option<aa_core::CapabilitySet> {
let raw = raw?;
for key in raw.unknown.keys() {
warnings.push(ValidationWarning::unknown_key(format!("capabilities.{}", key)));
}
let mut allow = std::collections::BTreeSet::new();
for (i, s) in raw.allow.unwrap_or_default().iter().enumerate() {
match s.parse::<aa_core::Capability>() {
Ok(cap) => {
allow.insert(cap);
}
Err(msg) => errors.push(ValidationError::new(format!("capabilities.allow[{}]", i), msg)),
}
}
let mut deny = std::collections::BTreeSet::new();
for (i, s) in raw.deny.unwrap_or_default().iter().enumerate() {
match s.parse::<aa_core::Capability>() {
Ok(cap) => {
deny.insert(cap);
}
Err(msg) => errors.push(ValidationError::new(format!("capabilities.deny[{}]", i), msg)),
}
}
Some(aa_core::CapabilitySet { allow, deny })
}
fn validate_tools(
raw: Option<HashMap<String, crate::policy::raw::RawToolPolicy>>,
errors: &mut Vec<ValidationError>,
warnings: &mut Vec<ValidationWarning>,
) -> HashMap<String, ToolPolicy> {
let raw = match raw {
Some(m) => m,
None => return HashMap::new(),
};
let mut tools = HashMap::new();
for (name, rt) in raw {
for key in rt.unknown.keys() {
warnings.push(ValidationWarning::unknown_key(format!("tools.{}.{}", name, key)));
}
if let Some(expr) = &rt.requires_approval_if {
if expr.trim().is_empty() {
errors.push(ValidationError::new(
format!("tools.{}.requires_approval_if", name),
"CEL expression must not be empty",
));
} else if let Err(msg) = super::expr::validate_governance_levels(expr) {
errors.push(ValidationError::new(
format!("tools.{}.requires_approval_if", name),
msg,
));
} else if let Err(e) = super::expr::validate_variables(expr) {
errors.push(ValidationError::new(
format!("tools.{}.requires_approval_if", name),
e.to_string(),
));
}
}
tools.insert(
name,
ToolPolicy {
allow: rt.allow.unwrap_or(true),
limit_per_hour: rt.limit_per_hour,
requires_approval_if: rt.requires_approval_if,
},
);
}
tools
}
fn validate_approval_policy(
raw: Option<crate::policy::raw::RawApprovalPolicy>,
_errors: &mut Vec<ValidationError>,
warnings: &mut Vec<ValidationWarning>,
) -> Option<ApprovalPolicy> {
let raw = raw?;
for key in raw.unknown.keys() {
warnings.push(ValidationWarning::unknown_key(format!("approval.{}", key)));
}
Some(ApprovalPolicy {
timeout_seconds: raw.timeout_seconds,
escalation_role: raw.escalation_role,
})
}
}
fn is_hhmm(s: &str) -> bool {
let parts: Vec<&str> = s.splitn(2, ':').collect();
if parts.len() != 2 {
return false;
}
match (parts[0].parse::<u8>(), parts[1].parse::<u8>()) {
(Ok(h), Ok(m)) => h < 24 && m < 60 && parts[0].len() == 2 && parts[1].len() == 2,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn top_level_unknown_key_produces_warning() {
let yaml = "risk_tier: high\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert!(out.warnings.iter().any(|w| w.field == "risk_tier"));
}
#[test]
fn network_unknown_key_produces_warning() {
let yaml = "network:\n allowlist:\n - api.openai.com\n blocklist:\n - \"*\"\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert!(out.warnings.iter().any(|w| w.field == "network.blocklist"));
}
#[test]
fn tool_unknown_key_produces_warning() {
let yaml = "tools:\n bash:\n allow: true\n constraint: read-only\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert!(out.warnings.iter().any(|w| w.field == "tools.bash.constraint"));
}
#[test]
fn network_empty_allowlist_entry_is_an_error() {
let yaml = "network:\n allowlist:\n - \"\"\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "network.allowlist[0]"));
}
#[test]
fn network_valid_allowlist_round_trips() {
let yaml = "network:\n allowlist:\n - api.openai.com\n - slack.com\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let np = out.document.network.unwrap();
assert_eq!(np.allowlist, vec!["api.openai.com", "slack.com"]);
}
#[test]
fn tool_empty_requires_approval_if_is_an_error() {
let yaml = "tools:\n bash:\n allow: true\n requires_approval_if: \" \"\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "tools.bash.requires_approval_if"));
}
#[test]
fn parser_rejects_unknown_governance_level() {
let yaml = "tools:\n bash:\n allow: true\n requires_approval_if: \"governance_level >= L4\"\n";
let errs = PolicyValidator::from_yaml(yaml).unwrap_err();
let err = errs
.iter()
.find(|e| e.field == "tools.bash.requires_approval_if")
.expect("validator should flag the unknown level on the requires_approval_if field");
assert_eq!(
err.message,
"unknown governance level: L4; valid values: L0, L1, L2, L3"
);
}
#[test]
fn validator_accepts_all_known_governance_levels() {
for lvl in ["L0", "L1", "L2", "L3"] {
let yaml =
format!("tools:\n bash:\n allow: true\n requires_approval_if: \"governance_level == {lvl}\"\n",);
assert!(
PolicyValidator::from_yaml(&yaml).is_ok(),
"validator unexpectedly rejected condition with {lvl}",
);
}
}
#[test]
fn tool_allow_defaults_to_true_when_absent() {
let yaml = "tools:\n bash:\n limit_per_hour: 5\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert!(out.document.tools["bash"].allow);
}
#[test]
fn tool_limit_per_hour_round_trips() {
let yaml = "tools:\n bash:\n allow: true\n limit_per_hour: 10\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert_eq!(out.document.tools["bash"].limit_per_hour, Some(10));
}
#[test]
fn data_invalid_regex_pattern_is_an_error() {
let yaml = "data:\n sensitive_patterns:\n - \"[unclosed\"\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "data.sensitive_patterns[0]"));
}
#[test]
fn data_valid_regex_patterns_round_trip() {
let yaml = "data:\n sensitive_patterns:\n - \"sk-[a-zA-Z0-9]{48}\"\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let dp = out.document.data.unwrap();
assert_eq!(dp.sensitive_patterns.len(), 1);
}
#[test]
fn data_credential_action_block_parses() {
let yaml = "data:\n credential_action: block\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let dp = out.document.data.unwrap();
assert_eq!(dp.credential_action, CredentialAction::Block);
}
#[test]
fn data_credential_action_alert_only_parses() {
let yaml = "data:\n credential_action: alert_only\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let dp = out.document.data.unwrap();
assert_eq!(dp.credential_action, CredentialAction::AlertOnly);
}
#[test]
fn data_credential_action_invalid_value_is_an_error() {
let yaml = "data:\n credential_action: not_a_real_mode\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "data.credential_action"));
}
#[test]
fn budget_zero_daily_limit_is_an_error() {
let yaml = "budget:\n daily_limit_usd: 0.0\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "budget.daily_limit_usd"));
}
#[test]
fn budget_negative_daily_limit_is_an_error() {
let yaml = "budget:\n daily_limit_usd: -1.0\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "budget.daily_limit_usd"));
}
#[test]
fn budget_valid_daily_limit_round_trips() {
let yaml = "budget:\n daily_limit_usd: 50.0\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let bp = out.document.budget.unwrap();
assert_eq!(bp.daily_limit_usd, Some(50.0));
}
#[test]
fn budget_timezone_valid_string_round_trips() {
let yaml = "budget:\n daily_limit_usd: 10.0\n timezone: \"Asia/Tokyo\"\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let bp = out.document.budget.unwrap();
assert_eq!(bp.timezone, Some("Asia/Tokyo".to_string()));
}
#[test]
fn budget_timezone_invalid_string_is_an_error() {
let yaml = "budget:\n daily_limit_usd: 10.0\n timezone: \"Not/AValidZone\"\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err(), "expected validation error for invalid timezone");
let errors = result.unwrap_err();
assert!(
errors.iter().any(|e| e.field == "budget.timezone"),
"expected error mentioning budget.timezone, got: {:?}",
errors
);
}
#[test]
fn budget_valid_monthly_limit_round_trips() {
let yaml = "budget:\n monthly_limit_usd: 500.0\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let bp = out.document.budget.unwrap();
assert_eq!(bp.monthly_limit_usd, Some(500.0));
}
#[test]
fn budget_negative_monthly_limit_is_an_error() {
let yaml = "budget:\n monthly_limit_usd: -10.0\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "budget.monthly_limit_usd"));
}
#[test]
fn budget_zero_monthly_limit_is_an_error() {
let yaml = "budget:\n monthly_limit_usd: 0.0\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "budget.monthly_limit_usd"));
}
#[test]
fn budget_monthly_less_than_daily_is_an_error() {
let yaml = "budget:\n daily_limit_usd: 100.0\n monthly_limit_usd: 50.0\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs
.iter()
.any(|e| e.field == "budget.monthly_limit_usd" && e.message.contains(">= daily_limit_usd")));
}
#[test]
fn budget_monthly_equal_to_daily_is_valid() {
let yaml = "budget:\n daily_limit_usd: 100.0\n monthly_limit_usd: 100.0\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let bp = out.document.budget.unwrap();
assert_eq!(bp.monthly_limit_usd, Some(100.0));
assert_eq!(bp.daily_limit_usd, Some(100.0));
}
#[test]
fn budget_monthly_without_daily_is_valid() {
let yaml = "budget:\n monthly_limit_usd: 1000.0\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let bp = out.document.budget.unwrap();
assert_eq!(bp.monthly_limit_usd, Some(1000.0));
assert!(bp.daily_limit_usd.is_none());
}
#[test]
fn budget_action_on_exceed_deny_round_trips() {
let yaml = "budget:\n daily_limit_usd: 50.0\n action_on_exceed: deny\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let bp = out.document.budget.unwrap();
assert_eq!(bp.action_on_exceed, ActionOnExceed::Deny);
}
#[test]
fn budget_action_on_exceed_suspend_round_trips() {
let yaml = "budget:\n daily_limit_usd: 50.0\n action_on_exceed: suspend\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let bp = out.document.budget.unwrap();
assert_eq!(bp.action_on_exceed, ActionOnExceed::Suspend);
}
#[test]
fn budget_action_on_exceed_invalid_value_is_an_error() {
let yaml = "budget:\n daily_limit_usd: 50.0\n action_on_exceed: quarantine\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "budget.action_on_exceed"));
}
#[test]
fn budget_action_on_exceed_absent_defaults_to_deny() {
let yaml = "budget:\n daily_limit_usd: 50.0\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let bp = out.document.budget.unwrap();
assert_eq!(bp.action_on_exceed, ActionOnExceed::Deny);
}
#[test]
fn schedule_invalid_start_format_is_an_error() {
let yaml = "schedule:\n active_hours:\n start: \"9:00\"\n end: \"18:00\"\n timezone: \"UTC\"\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "schedule.active_hours.start"));
}
#[test]
fn schedule_end_not_after_start_is_an_error() {
let yaml = "schedule:\n active_hours:\n start: \"18:00\"\n end: \"09:00\"\n timezone: \"UTC\"\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "schedule.active_hours"));
}
#[test]
fn schedule_valid_active_hours_round_trips() {
let yaml =
"schedule:\n active_hours:\n start: \"09:00\"\n end: \"18:00\"\n timezone: \"Asia/Taipei\"\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let sp = out.document.schedule.unwrap();
let ah = sp.active_hours.unwrap();
assert_eq!(ah.start, "09:00");
assert_eq!(ah.end, "18:00");
assert_eq!(ah.timezone, "Asia/Taipei");
}
#[test]
fn capabilities_valid_round_trips() {
let yaml = "capabilities:\n allow:\n - file_read\n - mcp_tool:bash\n deny:\n - terminal_exec\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
let caps = out.document.capabilities.as_ref().unwrap();
assert!(caps.allow.contains(&aa_core::Capability::FileRead));
assert!(caps.allow.contains(&aa_core::Capability::McpTool("bash".to_string())));
assert!(caps.deny.contains(&aa_core::Capability::TerminalExec));
}
#[test]
fn capabilities_absent_is_none() {
let yaml = "{}\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert!(out.document.capabilities.is_none());
}
#[test]
fn capabilities_unknown_string_is_validation_error() {
let yaml = "capabilities:\n allow:\n - unknown_thing\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "capabilities.allow[0]"));
}
#[test]
fn capabilities_mcp_tool_no_name_is_error() {
let yaml = "capabilities:\n allow:\n - \"mcp_tool:\"\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "capabilities.allow[0]"));
}
#[test]
fn capabilities_unknown_key_produces_warning() {
let yaml = "capabilities:\n allow: []\n extra_key: true\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert!(out.warnings.iter().any(|w| w.field == "capabilities.extra_key"));
}
#[test]
fn full_policy_document_validates_successfully() {
let yaml = r#"
version: "1.0"
network:
allowlist:
- api.openai.com
- slack.com
schedule:
active_hours:
start: "09:00"
end: "18:00"
timezone: "Asia/Taipei"
budget:
daily_limit_usd: 25.0
data:
sensitive_patterns:
- "sk-[a-zA-Z0-9]{48}"
tools:
bash:
allow: true
limit_per_hour: 10
requires_approval_if: "agent.depth > 1"
file_write:
allow: false
"#;
let out = PolicyValidator::from_yaml(yaml).unwrap();
let doc = &out.document;
assert_eq!(doc.version, Some("1.0".to_string()));
let np = doc.network.as_ref().unwrap();
assert_eq!(np.allowlist.len(), 2);
let sp = doc.schedule.as_ref().unwrap();
let ah = sp.active_hours.as_ref().unwrap();
assert_eq!(ah.timezone, "Asia/Taipei");
let bp = doc.budget.as_ref().unwrap();
assert_eq!(bp.daily_limit_usd, Some(25.0));
let dp = doc.data.as_ref().unwrap();
assert_eq!(dp.sensitive_patterns.len(), 1);
assert!(doc.tools["bash"].allow);
assert_eq!(doc.tools["bash"].limit_per_hour, Some(10));
assert!(!doc.tools["file_write"].allow);
assert!(out.warnings.is_empty());
}
#[test]
fn full_policy_with_multiple_errors_collects_all() {
let yaml = r#"
network:
allowlist:
- ""
budget:
daily_limit_usd: 0.0
data:
sensitive_patterns:
- "[bad"
"#;
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "network.allowlist[0]"));
assert!(errs.iter().any(|e| e.field == "budget.daily_limit_usd"));
assert!(errs.iter().any(|e| e.field == "data.sensitive_patterns[0]"));
}
#[test]
fn malformed_yaml_returns_parse_error() {
let yaml = ":\n bad: [unclosed\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert_eq!(errs[0].field, "(document)");
assert!(errs[0].message.contains("YAML parse error"));
}
#[test]
fn malformed_yaml_error_includes_line_number() {
let yaml = "network:\n allowlist:\n - [unclosed\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs[0].line.is_some(), "expected line number in parse error");
}
#[test]
fn empty_document_is_valid_with_no_errors() {
let yaml = "{}\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_ok());
let out = result.unwrap();
assert!(out.warnings.is_empty());
assert!(out.document.network.is_none());
}
#[test]
fn scope_absent_defaults_to_global_for_backward_compatibility() {
let yaml = "{}\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert_eq!(out.document.scope, PolicyScope::Global);
}
#[test]
fn scope_team_field_round_trips_through_validator() {
let yaml = "scope: team:platform\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert_eq!(out.document.scope, PolicyScope::Team("platform".to_owned()));
}
#[test]
fn scope_org_field_round_trips_through_validator() {
let yaml = "scope: org:acme\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert_eq!(out.document.scope, PolicyScope::Org("acme".to_owned()));
}
#[test]
fn scope_global_field_is_accepted() {
let yaml = "scope: global\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert_eq!(out.document.scope, PolicyScope::Global);
}
#[test]
fn malformed_scope_field_is_rejected_at_parse_time() {
let yaml = "scope: project:foo\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err(), "expected validation error for unknown scope kind");
let errs = result.unwrap_err();
assert!(
errs.iter().any(|e| e.message.contains("invalid policy scope")),
"expected error message mentioning invalid scope, got {:?}",
errs,
);
}
#[test]
fn every_malformed_scope_form_is_rejected_with_useful_diagnostic() {
let cases: &[(&str, &str)] = &[
("\"\"", "expected `global`"),
("acme", "expected `global`"),
("\"team:\"", "must not be empty"),
("\"tool:\"", "must not be empty"),
("project:foo", "unknown scope kind"),
("agent:not-a-uuid", "valid UUID"),
];
for (yaml_scalar, expected_substring) in cases {
let yaml = format!("scope: {}\n", yaml_scalar);
let result = PolicyValidator::from_yaml(&yaml);
assert!(result.is_err(), "expected error for malformed scope {:?}", yaml_scalar,);
let errs = result.unwrap_err();
assert!(
errs.iter().any(|e| e.message.contains(expected_substring)),
"for scope {:?} expected diagnostic containing {:?}, got {:?}",
yaml_scalar,
expected_substring,
errs,
);
}
}
#[test]
fn approval_timeout_valid_value_round_trips() {
let yaml = "approval_timeout_secs: 600\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert_eq!(out.document.approval_timeout_secs, 600);
}
#[test]
fn approval_timeout_absent_defaults_to_300() {
let yaml = "{}\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert_eq!(out.document.approval_timeout_secs, 300);
}
#[test]
fn approval_timeout_zero_is_an_error() {
let yaml = "approval_timeout_secs: 0\n";
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "approval_timeout_secs"));
}
#[test]
fn envelope_format_extracts_metadata_name_and_version() {
let yaml = r#"
apiVersion: agent-assembly/v1
kind: Policy
metadata:
name: my-policy
version: "2.0.0"
spec:
budget:
daily_limit_usd: 10.0
"#;
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert_eq!(out.document.name, Some("my-policy".to_string()));
assert_eq!(out.document.policy_version, Some("2.0.0".to_string()));
assert_eq!(out.document.budget.unwrap().daily_limit_usd, Some(10.0));
}
#[test]
fn envelope_format_with_tools_parses_spec_correctly() {
let yaml = r#"
apiVersion: agent-assembly/v1
kind: Policy
metadata:
name: test-policy
version: "1.0.0"
spec:
tools:
bash:
allow: true
limit_per_hour: 5
file_write:
allow: false
"#;
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert_eq!(out.document.name, Some("test-policy".to_string()));
assert_eq!(out.document.tools.len(), 2);
assert!(out.document.tools["bash"].allow);
assert!(!out.document.tools["file_write"].allow);
}
#[test]
fn flat_format_has_no_metadata() {
let yaml = "budget:\n daily_limit_usd: 25.0\n";
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert!(out.document.name.is_none());
assert!(out.document.policy_version.is_none());
assert_eq!(out.document.budget.unwrap().daily_limit_usd, Some(25.0));
}
#[test]
fn envelope_format_without_metadata_section() {
let yaml = r#"
apiVersion: agent-assembly/v1
kind: Policy
spec:
budget:
daily_limit_usd: 5.0
"#;
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert!(out.document.name.is_none());
assert!(out.document.policy_version.is_none());
assert_eq!(out.document.budget.unwrap().daily_limit_usd, Some(5.0));
}
#[test]
fn envelope_format_validation_errors_propagate() {
let yaml = r#"
apiVersion: agent-assembly/v1
kind: Policy
metadata:
name: bad-policy
spec:
budget:
daily_limit_usd: -1.0
"#;
let result = PolicyValidator::from_yaml(yaml);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| e.field == "budget.daily_limit_usd"));
}
#[test]
fn approval_policy_parses_timeout_and_role() {
let yaml = r#"
apiVersion: agent-assembly/v1
kind: Policy
metadata:
name: escalation-test
spec:
scope: global
approval:
timeout_seconds: 600
escalation_role: org-admin
"#;
let out = PolicyValidator::from_yaml(yaml).unwrap();
let ap = out.document.approval_policy.expect("approval_policy must be Some");
assert_eq!(ap.timeout_seconds, Some(600));
assert_eq!(ap.escalation_role, Some("org-admin".to_string()));
}
#[test]
fn approval_policy_absent_yields_none() {
let out = PolicyValidator::from_yaml("version: \"1\"\n").unwrap();
assert!(out.document.approval_policy.is_none());
}
#[test]
fn approval_policy_unknown_key_produces_warning() {
let yaml = r#"
apiVersion: agent-assembly/v1
kind: Policy
metadata:
name: warn-test
spec:
scope: global
approval:
timeout_seconds: 300
unknown_field: surprise
"#;
let out = PolicyValidator::from_yaml(yaml).unwrap();
assert!(
out.warnings.iter().any(|w| w.field.contains("unknown_field")),
"expected warning for unknown approval field, got: {:?}",
out.warnings,
);
}
}