#[cfg(feature = "alloc")]
pub type ArgsJson = alloc::string::String;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FileMode {
Read,
Write,
Append,
Delete,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PolicyError {
InvalidDocument,
UnknownAction,
EvaluationFailed,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PolicyDecision {
Allow,
Deny,
RequireApproval,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum EnforcementMode {
#[default]
Enforce,
Observe,
Disabled,
}
impl EnforcementMode {
pub fn from_proto_i32(v: i32) -> Option<Self> {
match v {
1 => Some(Self::Enforce),
2 => Some(Self::Observe),
3 => Some(Self::Disabled),
_ => None,
}
}
}
#[cfg(feature = "alloc")]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PolicyRule {
pub action_pattern: alloc::string::String,
pub decision: PolicyDecision,
}
#[cfg(feature = "alloc")]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PolicyDocument {
pub version: u32,
pub name: alloc::string::String,
pub rules: alloc::vec::Vec<PolicyRule>,
#[cfg_attr(feature = "serde", serde(default))]
pub enforcement_mode: EnforcementMode,
}
#[cfg(feature = "alloc")]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PolicyResult {
Allow,
Deny {
reason: alloc::string::String,
},
RequiresApproval {
timeout_secs: u32,
},
}
#[cfg(feature = "alloc")]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum GovernanceAction {
ToolCall {
name: alloc::string::String,
args: ArgsJson,
},
ToolResult {
tool_name: alloc::string::String,
result: ArgsJson,
},
FileAccess {
path: alloc::string::String,
mode: FileMode,
},
NetworkRequest {
url: alloc::string::String,
method: alloc::string::String,
},
ProcessExec {
command: alloc::string::String,
},
SendMessage {
source_team_id: Option<alloc::string::String>,
target_team_id: Option<alloc::string::String>,
channel_id: Option<alloc::string::String>,
},
}
#[cfg(feature = "alloc")]
pub trait PolicyEvaluator {
fn evaluate(&self, ctx: &crate::AgentContext, action: &GovernanceAction) -> PolicyResult;
fn load_policy(&mut self, policy: &PolicyDocument) -> Result<(), PolicyError>;
fn validate_policy(&self, policy: &PolicyDocument) -> Result<(), alloc::vec::Vec<PolicyError>>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_mode_clone_and_eq() {
let m = FileMode::Read;
assert_eq!(m.clone(), FileMode::Read);
assert_ne!(FileMode::Write, FileMode::Delete);
}
#[test]
fn file_mode_all_variants() {
assert_ne!(FileMode::Read, FileMode::Write);
assert_ne!(FileMode::Append, FileMode::Delete);
assert_ne!(FileMode::Write, FileMode::Append);
}
#[test]
#[cfg(feature = "alloc")]
fn governance_action_tool_call() {
let action = GovernanceAction::ToolCall {
name: alloc::string::String::from("list_files"),
args: alloc::string::String::from("{\"dir\":\"/tmp\"}"),
};
assert_eq!(action.clone(), action);
}
#[test]
#[cfg(feature = "alloc")]
fn governance_action_tool_result() {
let action = GovernanceAction::ToolResult {
tool_name: alloc::string::String::from("list_files"),
result: alloc::string::String::from("{\"entries\":[\"a.txt\"]}"),
};
assert_eq!(action.clone(), action);
}
#[test]
#[cfg(all(feature = "alloc", feature = "serde"))]
fn governance_action_tool_result_serde_round_trip() {
let action = GovernanceAction::ToolResult {
tool_name: alloc::string::String::from("read_file"),
result: alloc::string::String::from("{\"contents\":\"sk-test-abc\"}"),
};
let encoded = serde_json::to_string(&action).expect("serialize");
let decoded: GovernanceAction = serde_json::from_str(&encoded).expect("deserialize");
assert_eq!(decoded, action);
}
#[test]
#[cfg(feature = "alloc")]
fn governance_action_file_access() {
let action = GovernanceAction::FileAccess {
path: alloc::string::String::from("/etc/passwd"),
mode: FileMode::Read,
};
let cloned = action.clone();
assert_eq!(action, cloned);
}
#[test]
#[cfg(feature = "alloc")]
fn governance_action_network_request() {
let action = GovernanceAction::NetworkRequest {
url: alloc::string::String::from("https://example.com"),
method: alloc::string::String::from("GET"),
};
assert_eq!(action.clone(), action);
}
#[test]
#[cfg(feature = "alloc")]
fn governance_action_spawn() {
let action = GovernanceAction::ProcessExec {
command: alloc::string::String::from("ls -la"),
};
assert_eq!(action.clone(), action);
}
#[test]
#[cfg(feature = "alloc")]
fn policy_result_allow() {
assert_eq!(PolicyResult::Allow, PolicyResult::Allow);
assert_eq!(PolicyResult::Allow.clone(), PolicyResult::Allow);
}
#[test]
#[cfg(feature = "alloc")]
fn policy_result_deny_reason() {
let r = PolicyResult::Deny {
reason: alloc::string::String::from("blocked"),
};
if let PolicyResult::Deny { reason } = &r {
assert_eq!(reason, "blocked");
} else {
panic!("expected Deny");
}
}
#[test]
#[cfg(feature = "alloc")]
fn policy_result_requires_approval() {
let r = PolicyResult::RequiresApproval { timeout_secs: 30 };
if let PolicyResult::RequiresApproval { timeout_secs } = r {
assert_eq!(timeout_secs, 30);
} else {
panic!("expected RequiresApproval");
}
}
#[test]
fn policy_error_variants() {
assert_eq!(PolicyError::InvalidDocument, PolicyError::InvalidDocument);
assert_ne!(PolicyError::UnknownAction, PolicyError::EvaluationFailed);
}
#[test]
fn policy_decision_variants() {
assert_eq!(PolicyDecision::Allow, PolicyDecision::Allow);
assert_ne!(PolicyDecision::Deny, PolicyDecision::RequireApproval);
}
#[test]
fn enforcement_mode_default_is_enforce() {
assert_eq!(EnforcementMode::default(), EnforcementMode::Enforce);
}
#[test]
fn enforcement_mode_from_proto_i32_round_trips_known_values() {
assert_eq!(EnforcementMode::from_proto_i32(1), Some(EnforcementMode::Enforce));
assert_eq!(EnforcementMode::from_proto_i32(2), Some(EnforcementMode::Observe));
assert_eq!(EnforcementMode::from_proto_i32(3), Some(EnforcementMode::Disabled));
assert_eq!(EnforcementMode::from_proto_i32(0), None);
assert_eq!(EnforcementMode::from_proto_i32(-1), None);
assert_eq!(EnforcementMode::from_proto_i32(99), None);
}
#[cfg(feature = "serde")]
#[test]
fn enforcement_mode_serde_snake_case_round_trip() {
for (mode, expected) in [
(EnforcementMode::Enforce, "\"enforce\""),
(EnforcementMode::Observe, "\"observe\""),
(EnforcementMode::Disabled, "\"disabled\""),
] {
let json = serde_json::to_string(&mode).unwrap();
assert_eq!(json, expected, "{mode:?} must serialise as {expected}");
let back: EnforcementMode = serde_json::from_str(&json).unwrap();
assert_eq!(back, mode, "{expected} must deserialise back to {mode:?}");
}
}
#[test]
#[cfg(feature = "alloc")]
fn policy_rule_field_access_clone_eq() {
let rule = PolicyRule {
action_pattern: alloc::string::String::from("tool_call/*"),
decision: PolicyDecision::Deny,
};
let cloned = rule.clone();
assert_eq!(rule, cloned);
assert_eq!(rule.action_pattern, "tool_call/*");
assert_eq!(rule.decision, PolicyDecision::Deny);
}
#[test]
#[cfg(feature = "alloc")]
fn policy_document_field_access_clone_eq() {
let doc = PolicyDocument {
version: 1,
name: alloc::string::String::from("test-policy"),
rules: alloc::vec![PolicyRule {
action_pattern: alloc::string::String::from("*"),
decision: PolicyDecision::Allow,
}],
enforcement_mode: EnforcementMode::default(),
};
let cloned = doc.clone();
assert_eq!(doc, cloned);
assert_eq!(doc.version, 1);
assert_eq!(doc.name, "test-policy");
assert_eq!(doc.rules.len(), 1);
assert_eq!(doc.rules[0].decision, PolicyDecision::Allow);
}
#[cfg(all(feature = "alloc", feature = "serde"))]
#[test]
fn policy_document_enforcement_mode_parses_observe_from_yaml() {
let yaml = "version: 1\nname: sandbox-policy\nenforcement_mode: observe\nrules: []\n";
let doc: PolicyDocument = serde_yaml::from_str(yaml).unwrap();
assert_eq!(doc.enforcement_mode, EnforcementMode::Observe);
}
#[cfg(all(feature = "alloc", feature = "serde"))]
#[test]
fn policy_document_enforcement_mode_defaults_to_enforce_when_absent() {
let yaml = "version: 1\nname: legacy-policy\nrules: []\n";
let doc: PolicyDocument = serde_yaml::from_str(yaml).unwrap();
assert_eq!(doc.enforcement_mode, EnforcementMode::Enforce);
}
#[test]
#[cfg(feature = "alloc")]
fn policy_result_cross_variant_inequality() {
assert_ne!(
PolicyResult::Allow,
PolicyResult::Deny {
reason: alloc::string::String::from("x")
}
);
assert_ne!(
PolicyResult::Deny {
reason: alloc::string::String::from("x")
},
PolicyResult::RequiresApproval { timeout_secs: 10 }
);
}
}
#[cfg(feature = "alloc")]
pub fn is_host_allowed_by_egress_allowlist(host: &str, allowlist: &[alloc::string::String]) -> bool {
if allowlist.is_empty() {
return true;
}
let host_lower = host.to_ascii_lowercase();
for pattern in allowlist {
if egress_pattern_matches(pattern, &host_lower) {
return true;
}
}
false
}
#[cfg(feature = "alloc")]
fn egress_pattern_matches(pattern: &str, host_lower: &str) -> bool {
let pattern_lower = pattern.to_ascii_lowercase();
if pattern_lower == "*" {
return true;
}
if let Some(suffix) = pattern_lower.strip_prefix("*.") {
let required_suffix = alloc::format!(".{suffix}");
return host_lower.ends_with(&required_suffix) && host_lower.len() > required_suffix.len();
}
pattern_lower == host_lower
}
#[cfg(all(test, feature = "alloc"))]
mod egress_tests {
use alloc::string::ToString;
use alloc::vec;
use super::is_host_allowed_by_egress_allowlist;
#[test]
fn empty_allowlist_is_default_allow() {
assert!(is_host_allowed_by_egress_allowlist("api.example.com", &[]));
assert!(is_host_allowed_by_egress_allowlist("evil.attacker.net", &[]));
}
#[test]
fn exact_match_only_matches_exact_host() {
let list = vec!["api.openai.com".to_string()];
assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
assert!(!is_host_allowed_by_egress_allowlist("chat.openai.com", &list));
assert!(!is_host_allowed_by_egress_allowlist("openai.com", &list));
assert!(!is_host_allowed_by_egress_allowlist("attackerapi.openai.com", &list));
}
#[test]
fn case_insensitive_host_match() {
let list = vec!["API.OpenAI.com".to_string()];
assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
assert!(is_host_allowed_by_egress_allowlist("API.OPENAI.COM", &list));
}
#[test]
fn leftmost_wildcard_matches_subdomain() {
let list = vec!["*.openai.com".to_string()];
assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
assert!(is_host_allowed_by_egress_allowlist("chat.openai.com", &list));
assert!(is_host_allowed_by_egress_allowlist("a.b.openai.com", &list));
}
#[test]
fn leftmost_wildcard_does_not_match_bare_suffix() {
let list = vec!["*.openai.com".to_string()];
assert!(!is_host_allowed_by_egress_allowlist("openai.com", &list));
}
#[test]
fn leftmost_wildcard_does_not_match_attacker_crafted_suffix() {
let list = vec!["*.openai.com".to_string()];
assert!(!is_host_allowed_by_egress_allowlist(
"evil.openai.com.attacker.net",
&list
));
}
#[test]
fn universal_wildcard_matches_any_host() {
let list = vec!["*".to_string()];
assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
assert!(is_host_allowed_by_egress_allowlist("evil.attacker.net", &list));
assert!(is_host_allowed_by_egress_allowlist("anything", &list));
}
#[test]
fn multiple_patterns_any_match_allows() {
let list = vec!["api.openai.com".to_string(), "*.anthropic.com".to_string()];
assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
assert!(is_host_allowed_by_egress_allowlist("api.anthropic.com", &list));
assert!(!is_host_allowed_by_egress_allowlist("api.cohere.com", &list));
}
}