use std::sync::Arc;
use crate::policy::document::PolicyDocument;
use crate::policy::scope::PolicyScope;
#[derive(Debug, Clone, PartialEq)]
pub enum PolicyDecision {
Allow,
RequireApproval { reason: String, timeout_secs: u32 },
Deny { reason: String, source_scope: PolicyScope },
}
impl PolicyDecision {
pub fn into_policy_result(self) -> aa_core::PolicyResult {
match self {
PolicyDecision::Allow => aa_core::PolicyResult::Allow,
PolicyDecision::RequireApproval { timeout_secs, .. } => {
aa_core::PolicyResult::RequiresApproval { timeout_secs }
}
PolicyDecision::Deny { reason, .. } => aa_core::PolicyResult::Deny { reason },
}
}
}
pub(crate) fn evaluate_single_doc(
doc: &PolicyDocument,
ctx: &aa_core::AgentContext,
action: &aa_core::GovernanceAction,
policy_ctx: Option<&dyn crate::policy::context::PolicyContext>,
) -> PolicyDecision {
if let Some(d) = stage_schedule(doc) {
return d;
}
if let Some(d) = stage_network(doc, action) {
return d;
}
if let Some(d) = stage_tool_allow(doc, action) {
return d;
}
if let Some(d) = stage_capability(doc, action) {
return d;
}
if let Some(d) = stage_approval(doc, ctx, action, policy_ctx) {
return d;
}
PolicyDecision::Allow
}
fn stage_schedule(doc: &PolicyDocument) -> Option<PolicyDecision> {
let ah = doc.schedule.as_ref()?.active_hours.as_ref()?;
use chrono::Timelike;
let Ok(tz) = ah.timezone.parse::<chrono_tz::Tz>() else {
return Some(PolicyDecision::Deny {
reason: format!("invalid schedule timezone: {}", ah.timezone),
source_scope: doc.scope.clone(),
});
};
let now = chrono::Utc::now().with_timezone(&tz);
let current_hhmm = format!("{:02}:{:02}", now.hour(), now.minute());
if current_hhmm < ah.start || current_hhmm >= ah.end {
return Some(PolicyDecision::Deny {
reason: "outside active hours".into(),
source_scope: doc.scope.clone(),
});
}
None
}
fn stage_network(doc: &PolicyDocument, action: &aa_core::GovernanceAction) -> Option<PolicyDecision> {
let aa_core::GovernanceAction::NetworkRequest { url, .. } = action else {
return None;
};
let np = doc.network.as_ref()?;
let host_port = url
.split_once("://")
.map(|x| x.1)
.unwrap_or("")
.split('/')
.next()
.unwrap_or("");
let host = match host_port.rsplit_once(':') {
Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h,
_ => host_port,
};
let allowed = !np.allowlist.is_empty() && aa_core::policy::is_host_allowed_by_egress_allowlist(host, &np.allowlist);
if !allowed {
return Some(PolicyDecision::Deny {
reason: "host not in network allowlist".into(),
source_scope: doc.scope.clone(),
});
}
None
}
fn stage_tool_allow(doc: &PolicyDocument, action: &aa_core::GovernanceAction) -> Option<PolicyDecision> {
let aa_core::GovernanceAction::ToolCall { name, .. } = action else {
return None;
};
let tp = doc.tools.get(name)?;
if !tp.allow {
return Some(PolicyDecision::Deny {
reason: "tool denied by policy".into(),
source_scope: doc.scope.clone(),
});
}
None
}
fn stage_capability(doc: &PolicyDocument, action: &aa_core::GovernanceAction) -> Option<PolicyDecision> {
let caps = doc.capabilities.as_ref()?;
let cap = aa_core::action_to_capability(action)?;
if caps.deny.contains(&cap) {
return Some(PolicyDecision::Deny {
reason: "capability denied by policy".into(),
source_scope: doc.scope.clone(),
});
}
if !caps.allow.is_empty() && !caps.allow.contains(&cap) {
return Some(PolicyDecision::Deny {
reason: "capability not in allow list".into(),
source_scope: doc.scope.clone(),
});
}
None
}
fn stage_approval(
doc: &PolicyDocument,
ctx: &aa_core::AgentContext,
action: &aa_core::GovernanceAction,
policy_ctx: Option<&dyn crate::policy::context::PolicyContext>,
) -> Option<PolicyDecision> {
match action {
aa_core::GovernanceAction::ToolCall { name, .. }
if approval_condition_met(doc.tools.get(name), ctx, action, policy_ctx) =>
{
Some(PolicyDecision::RequireApproval {
reason: format!("approval required for tool '{name}'"),
timeout_secs: doc.approval_timeout_secs,
})
}
aa_core::GovernanceAction::SendMessage { .. }
if approval_condition_met(doc.tools.get("message"), ctx, action, policy_ctx) =>
{
Some(PolicyDecision::RequireApproval {
reason: "approval required: cross-team channel policy".into(),
timeout_secs: doc.approval_timeout_secs,
})
}
_ => None,
}
}
fn approval_condition_met(
tool_policy: Option<&crate::policy::document::ToolPolicy>,
ctx: &aa_core::AgentContext,
action: &aa_core::GovernanceAction,
policy_ctx: Option<&dyn crate::policy::context::PolicyContext>,
) -> bool {
let Some(expr) = tool_policy.and_then(|tp| tp.requires_approval_if.as_ref()) else {
return false;
};
!expr.is_empty() && crate::policy::expr::evaluate(expr, action, Some(ctx.governance_level), policy_ctx)
}
pub fn merge_decisions(
cascade: &[Arc<PolicyDocument>],
ctx: &aa_core::AgentContext,
action: &aa_core::GovernanceAction,
policy_ctx: Option<&dyn crate::policy::context::PolicyContext>,
) -> PolicyDecision {
if cascade.is_empty() {
return PolicyDecision::Deny {
reason: "no policy — fail-closed".into(),
source_scope: PolicyScope::Global,
};
}
let mut running = PolicyDecision::Allow;
for doc in cascade {
let verdict = evaluate_single_doc(doc, ctx, action, policy_ctx);
match verdict {
PolicyDecision::Deny { .. } => return verdict,
PolicyDecision::RequireApproval { .. } => {
running = verdict;
}
PolicyDecision::Allow => {}
}
}
running
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::document::PolicyDocument;
use crate::policy::scope::PolicyScope;
use aa_core::{
identity::{AgentId, SessionId},
time::Timestamp,
AgentContext, Capability, CapabilitySet, FileMode, GovernanceAction, GovernanceLevel,
};
use std::collections::{BTreeMap, BTreeSet, HashMap};
fn make_ctx() -> AgentContext {
AgentContext {
agent_id: AgentId::from_bytes([1u8; 16]),
session_id: SessionId::from_bytes([2u8; 16]),
pid: 1,
started_at: Timestamp::from_nanos(0),
metadata: BTreeMap::new(),
governance_level: GovernanceLevel::default(),
parent_agent_id: None,
team_id: None,
depth: 0,
delegation_reason: None,
spawned_by_tool: None,
root_agent_id: None,
}
}
fn minimal_doc(caps: Option<CapabilitySet>) -> PolicyDocument {
PolicyDocument {
name: None,
policy_version: None,
version: None,
scope: PolicyScope::Global,
network: None,
schedule: None,
budget: None,
data: None,
approval_timeout_secs: 300,
approval_policy: None,
tools: HashMap::new(),
capabilities: caps,
}
}
fn cap_set(allow: &[Capability], deny: &[Capability]) -> CapabilitySet {
CapabilitySet {
allow: allow.iter().cloned().collect::<BTreeSet<_>>(),
deny: deny.iter().cloned().collect::<BTreeSet<_>>(),
}
}
#[test]
fn evaluate_single_doc_denies_capability_in_deny_set() {
let doc = minimal_doc(Some(cap_set(&[], &[Capability::FileRead])));
let ctx = make_ctx();
let action = GovernanceAction::FileAccess {
path: "/tmp/f".into(),
mode: FileMode::Read,
};
let result = evaluate_single_doc(&doc, &ctx, &action, None);
assert_eq!(
result,
PolicyDecision::Deny {
reason: "capability denied by policy".into(),
source_scope: PolicyScope::Global,
}
);
}
#[test]
fn evaluate_single_doc_denies_capability_not_in_allow_set() {
let doc = minimal_doc(Some(cap_set(&[Capability::FileRead], &[])));
let ctx = make_ctx();
let action = GovernanceAction::FileAccess {
path: "/tmp/f".into(),
mode: FileMode::Write,
};
let result = evaluate_single_doc(&doc, &ctx, &action, None);
assert_eq!(
result,
PolicyDecision::Deny {
reason: "capability not in allow list".into(),
source_scope: PolicyScope::Global,
}
);
}
#[test]
fn evaluate_single_doc_allows_capability_in_allow_set() {
let doc = minimal_doc(Some(cap_set(&[Capability::FileRead], &[])));
let ctx = make_ctx();
let action = GovernanceAction::FileAccess {
path: "/tmp/f".into(),
mode: FileMode::Read,
};
let result = evaluate_single_doc(&doc, &ctx, &action, None);
assert_eq!(result, PolicyDecision::Allow);
}
#[test]
fn evaluate_single_doc_no_capabilities_field_allows_all() {
let doc = minimal_doc(None);
let ctx = make_ctx();
let action = GovernanceAction::FileAccess {
path: "/tmp/f".into(),
mode: FileMode::Write,
};
let result = evaluate_single_doc(&doc, &ctx, &action, None);
assert_eq!(result, PolicyDecision::Allow);
}
#[test]
fn evaluate_single_doc_mcp_tool_denied_by_name() {
let doc = minimal_doc(Some(cap_set(&[], &[Capability::McpTool("bash".into())])));
let ctx = make_ctx();
let action = GovernanceAction::ToolCall {
name: "bash".into(),
args: "{}".into(),
};
let result = evaluate_single_doc(&doc, &ctx, &action, None);
assert_eq!(
result,
PolicyDecision::Deny {
reason: "capability denied by policy".into(),
source_scope: PolicyScope::Global,
}
);
}
#[test]
fn evaluate_single_doc_mcp_tool_allowed_by_name() {
let doc = minimal_doc(Some(cap_set(&[Capability::McpTool("bash".into())], &[])));
let ctx = make_ctx();
let action = GovernanceAction::ToolCall {
name: "bash".into(),
args: "{}".into(),
};
let result = evaluate_single_doc(&doc, &ctx, &action, None);
assert_eq!(result, PolicyDecision::Allow);
}
fn doc_with_allowlist(allowlist: Vec<String>) -> PolicyDocument {
let mut doc = minimal_doc(None);
doc.network = Some(crate::policy::document::NetworkPolicy { allowlist });
doc
}
fn net_action(url: &str) -> GovernanceAction {
GovernanceAction::NetworkRequest {
url: url.into(),
method: "GET".into(),
}
}
#[test]
fn stage_network_wildcard_matches_subdomain() {
let doc = doc_with_allowlist(vec!["*.openai.com".into()]);
assert_eq!(stage_network(&doc, &net_action("https://api.openai.com/v1")), None);
}
#[test]
fn stage_network_wildcard_denies_non_matching_host() {
let doc = doc_with_allowlist(vec!["*.openai.com".into()]);
let d = stage_network(&doc, &net_action("https://evil.attacker.net/x")).expect("deny");
assert!(matches!(d, PolicyDecision::Deny { .. }));
}
#[test]
fn stage_network_wildcard_denies_bare_apex() {
let doc = doc_with_allowlist(vec!["*.openai.com".into()]);
let d = stage_network(&doc, &net_action("https://openai.com/")).expect("deny");
assert!(matches!(d, PolicyDecision::Deny { .. }));
}
#[test]
fn stage_network_exact_match_allows() {
let doc = doc_with_allowlist(vec!["api.openai.com".into()]);
assert_eq!(stage_network(&doc, &net_action("https://api.openai.com/v1")), None);
}
#[test]
fn stage_network_empty_allowlist_denies_all() {
let doc = doc_with_allowlist(vec![]);
let d = stage_network(&doc, &net_action("https://api.openai.com/v1")).expect("deny");
assert!(matches!(d, PolicyDecision::Deny { .. }));
}
#[test]
fn stage_network_no_network_section_is_noop() {
let doc = minimal_doc(None);
assert_eq!(stage_network(&doc, &net_action("https://anything.test/")), None);
}
#[test]
fn stage_network_allowlisted_host_with_port_allows() {
let doc = doc_with_allowlist(vec!["api.openai.com".into()]);
assert_eq!(stage_network(&doc, &net_action("https://api.openai.com:443/v1")), None);
}
#[test]
fn stage_network_wildcard_host_with_port_allows() {
let doc = doc_with_allowlist(vec!["*.openai.com".into()]);
assert_eq!(stage_network(&doc, &net_action("https://api.openai.com:443/v1")), None);
}
#[test]
fn stage_network_non_allowlisted_host_with_port_denies() {
let doc = doc_with_allowlist(vec!["api.openai.com".into()]);
let d = stage_network(&doc, &net_action("https://evil.attacker.net:8443/x")).expect("deny");
assert!(matches!(d, PolicyDecision::Deny { .. }));
}
fn doc_with_schedule(tz: &str, start: &str, end: &str) -> PolicyDocument {
let mut doc = minimal_doc(None);
doc.schedule = Some(crate::policy::document::SchedulePolicy {
active_hours: Some(crate::policy::document::ActiveHours {
start: start.into(),
end: end.into(),
timezone: tz.into(),
}),
});
doc
}
#[test]
fn stage_schedule_invalid_timezone_fails_closed() {
let doc = doc_with_schedule("Mars/Phobos", "00:00", "23:59");
let d = stage_schedule(&doc).expect("deny");
match d {
PolicyDecision::Deny { reason, .. } => {
assert!(reason.contains("invalid schedule timezone"), "got: {reason}");
}
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn stage_schedule_valid_timezone_full_day_window_allows() {
let doc = doc_with_schedule("UTC", "00:00", "23:59");
assert_eq!(stage_schedule(&doc), None);
}
}