use std::{collections::HashMap, sync::Arc};
use regex::Regex;
use serde::{Deserialize, Serialize};
use tracing::warn;
use super::audit::{AuditLog, DecisionResult, DecisionSource};
use crate::{
annotations::AnnotationType, inventory::QualifiedToolName, tenant::TenantContext,
ToolAnnotations,
};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum PolicyDecision {
#[default]
Allow,
Deny,
DenyWithReason(Arc<str>),
}
impl PolicyDecision {
pub fn deny_with_reason(reason: impl AsRef<str>) -> Self {
Self::DenyWithReason(Arc::from(reason.as_ref()))
}
pub fn is_allowed(&self) -> bool {
matches!(self, PolicyDecision::Allow)
}
pub fn denial_reason(&self) -> Option<&str> {
match self {
PolicyDecision::DenyWithReason(reason) => Some(reason),
PolicyDecision::Deny => Some("Policy denied"),
PolicyDecision::Allow => None,
}
}
}
impl Serialize for PolicyDecision {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
PolicyDecision::Allow => serializer.serialize_str("allow"),
PolicyDecision::Deny => serializer.serialize_str("deny"),
PolicyDecision::DenyWithReason(r) => {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("deny_with_reason", r.as_ref())?;
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for PolicyDecision {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::{self, MapAccess, Visitor};
struct PolicyDecisionVisitor;
impl<'de> Visitor<'de> for PolicyDecisionVisitor {
type Value = PolicyDecision;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("\"allow\", \"deny\", or {\"deny_with_reason\": \"...\"}")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
match v {
"allow" => Ok(PolicyDecision::Allow),
"deny" => Ok(PolicyDecision::Deny),
_ => Err(E::unknown_variant(v, &["allow", "deny"])),
}
}
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Self::Value, M::Error> {
if let Some(key) = map.next_key::<&str>()? {
if key == "deny_with_reason" {
let reason: String = map.next_value()?;
return Ok(PolicyDecision::DenyWithReason(Arc::from(reason)));
}
}
Err(de::Error::custom("expected deny_with_reason key"))
}
}
deserializer.deserialize_any(PolicyDecisionVisitor)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TrustLevel {
Trusted,
#[default]
Standard,
Untrusted,
Sandboxed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerPolicy {
pub default: PolicyDecision,
pub trust_level: TrustLevel,
}
impl Default for ServerPolicy {
fn default() -> Self {
Self {
default: PolicyDecision::Allow,
trust_level: TrustLevel::Standard,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolPolicy {
pub decision: PolicyDecision,
}
#[derive(Debug, Clone)]
pub enum RulePattern {
Server(Regex),
Tool(Regex),
Qualified(Regex),
Any,
}
impl RulePattern {
pub fn matches(&self, server_key: &str, tool_name: &str) -> bool {
match self {
RulePattern::Server(re) => re.is_match(server_key),
RulePattern::Tool(re) => re.is_match(tool_name),
RulePattern::Qualified(re) => {
let qualified = format!("{server_key}:{tool_name}");
re.is_match(&qualified)
}
RulePattern::Any => true,
}
}
}
#[derive(Debug, Clone)]
pub enum RuleCondition {
Always,
HasAnnotation(AnnotationType),
LacksAnnotation(AnnotationType),
}
impl RuleCondition {
pub fn evaluate(&self, hints: &ToolAnnotations) -> bool {
match self {
RuleCondition::Always => true,
RuleCondition::HasAnnotation(ann_type) => ann_type.matches(hints),
RuleCondition::LacksAnnotation(ann_type) => !ann_type.matches(hints),
}
}
}
#[derive(Debug, Clone)]
pub struct PolicyRule {
pub name: String,
pub pattern: RulePattern,
pub condition: RuleCondition,
pub decision: PolicyDecision,
}
impl PolicyRule {
pub fn new(
name: impl Into<String>,
pattern: RulePattern,
condition: RuleCondition,
decision: PolicyDecision,
) -> Self {
Self {
name: name.into(),
pattern,
condition,
decision,
}
}
pub fn evaluate(
&self,
server_key: &str,
tool_name: &str,
hints: &ToolAnnotations,
) -> Option<PolicyDecision> {
if self.pattern.matches(server_key, tool_name) && self.condition.evaluate(hints) {
Some(self.decision.clone())
} else {
None
}
}
}
pub struct PolicyEngine {
default_policy: PolicyDecision,
server_policies: HashMap<String, ServerPolicy>,
tool_policies: HashMap<QualifiedToolName, ToolPolicy>,
rules: Vec<PolicyRule>,
audit_log: Arc<AuditLog>,
}
impl PolicyEngine {
pub fn new(audit_log: Arc<AuditLog>) -> Self {
Self {
default_policy: PolicyDecision::Allow,
server_policies: HashMap::new(),
tool_policies: HashMap::new(),
rules: Vec::new(),
audit_log,
}
}
pub fn with_default_policy(mut self, policy: PolicyDecision) -> Self {
self.default_policy = policy;
self
}
pub fn with_server_policy(
mut self,
server_key: impl Into<String>,
policy: ServerPolicy,
) -> Self {
self.server_policies.insert(server_key.into(), policy);
self
}
pub fn with_tool_policy(
mut self,
qualified_name: QualifiedToolName,
policy: ToolPolicy,
) -> Self {
self.tool_policies.insert(qualified_name, policy);
self
}
pub fn with_rule(mut self, rule: PolicyRule) -> Self {
self.rules.push(rule);
self
}
pub fn evaluate(
&self,
server_key: &str,
tool_name: &str,
hints: &ToolAnnotations,
tenant_ctx: &TenantContext,
request_id: &str,
) -> PolicyDecision {
let qualified = QualifiedToolName::new(server_key, tool_name);
if let Some(policy) = self.tool_policies.get(&qualified) {
self.log_decision(
&qualified,
tenant_ctx,
request_id,
&policy.decision,
DecisionSource::ExplicitToolPolicy,
);
return policy.decision.clone();
}
if let Some(server_policy) = self.server_policies.get(server_key) {
let decision =
Self::evaluate_with_trust(server_policy.trust_level, hints, &server_policy.default);
if matches!(server_policy.trust_level, TrustLevel::Trusted)
|| !matches!(decision, PolicyDecision::Allow)
{
self.log_decision(
&qualified,
tenant_ctx,
request_id,
&decision,
DecisionSource::ServerPolicy,
);
return decision;
}
}
for rule in &self.rules {
if let Some(decision) = rule.evaluate(server_key, tool_name, hints) {
self.log_decision(
&qualified,
tenant_ctx,
request_id,
&decision,
DecisionSource::RuleMatch,
);
return decision;
}
}
let decision = self.annotation_based_decision(hints);
self.log_decision(
&qualified,
tenant_ctx,
request_id,
&decision,
DecisionSource::AnnotationDefault,
);
decision
}
fn evaluate_with_trust(
trust_level: TrustLevel,
hints: &ToolAnnotations,
server_default: &PolicyDecision,
) -> PolicyDecision {
match trust_level {
TrustLevel::Trusted => PolicyDecision::Allow,
TrustLevel::Standard => server_default.clone(),
TrustLevel::Untrusted => {
if hints.destructive && !hints.read_only {
PolicyDecision::deny_with_reason(
"Untrusted server: destructive operation denied",
)
} else {
server_default.clone()
}
}
TrustLevel::Sandboxed => {
if hints.open_world {
PolicyDecision::deny_with_reason("Sandboxed server: external access denied")
} else if hints.read_only {
PolicyDecision::Allow
} else {
PolicyDecision::deny_with_reason("Sandboxed server: write operations denied")
}
}
}
}
fn annotation_based_decision(&self, hints: &ToolAnnotations) -> PolicyDecision {
if hints.read_only {
PolicyDecision::Allow
} else if hints.destructive {
PolicyDecision::deny_with_reason("Destructive operation requires explicit policy")
} else {
self.default_policy.clone()
}
}
fn log_decision(
&self,
qualified: &QualifiedToolName,
tenant_ctx: &TenantContext,
request_id: &str,
decision: &PolicyDecision,
source: DecisionSource,
) {
let result = match decision {
PolicyDecision::Allow => DecisionResult::Approved,
PolicyDecision::Deny => DecisionResult::Denied {
reason: "Policy denied".to_string(),
},
PolicyDecision::DenyWithReason(reason) => DecisionResult::Denied {
reason: reason.to_string(),
},
};
self.audit_log.record_decision(
qualified,
&tenant_ctx.tenant_id,
request_id,
result,
source,
);
}
pub fn audit_log(&self) -> &Arc<AuditLog> {
&self.audit_log
}
}
impl Default for PolicyEngine {
fn default() -> Self {
let audit_log = Arc::new(AuditLog::new());
Self::new(audit_log).with_rule(PolicyRule::new(
"allow_read_only",
RulePattern::Any,
RuleCondition::HasAnnotation(AnnotationType::ReadOnly),
PolicyDecision::Allow,
))
}
}
use crate::core::config::{
PolicyConfig, PolicyDecisionConfig, ServerPolicyConfig, TrustLevelConfig,
};
impl From<PolicyDecisionConfig> for PolicyDecision {
fn from(config: PolicyDecisionConfig) -> Self {
match config {
PolicyDecisionConfig::Allow => PolicyDecision::Allow,
PolicyDecisionConfig::Deny => PolicyDecision::Deny,
PolicyDecisionConfig::DenyWithReason(reason) => {
PolicyDecision::DenyWithReason(Arc::from(reason))
}
}
}
}
impl From<TrustLevelConfig> for TrustLevel {
fn from(config: TrustLevelConfig) -> Self {
match config {
TrustLevelConfig::Trusted => TrustLevel::Trusted,
TrustLevelConfig::Standard => TrustLevel::Standard,
TrustLevelConfig::Untrusted => TrustLevel::Untrusted,
TrustLevelConfig::Sandboxed => TrustLevel::Sandboxed,
}
}
}
impl From<ServerPolicyConfig> for ServerPolicy {
fn from(config: ServerPolicyConfig) -> Self {
Self {
default: config.default.into(),
trust_level: config.trust_level.into(),
}
}
}
impl PolicyEngine {
pub fn from_yaml_config(config: &PolicyConfig, audit_log: Arc<AuditLog>) -> Self {
let mut engine = Self {
default_policy: config.default.clone().into(),
server_policies: config
.servers
.iter()
.map(|(k, v)| (k.clone(), v.clone().into()))
.collect(),
tool_policies: HashMap::new(),
rules: Vec::new(),
audit_log,
};
for (qualified_str, decision) in &config.tools {
if let Some((server, tool)) = qualified_str.split_once(':') {
engine.tool_policies.insert(
QualifiedToolName::new(server, tool),
ToolPolicy {
decision: decision.clone().into(),
},
);
} else {
warn!(
"Invalid tool policy key '{}': expected 'server:tool' format",
qualified_str
);
}
}
engine
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_engine() -> PolicyEngine {
PolicyEngine::new(Arc::new(AuditLog::new()))
}
#[test]
fn test_default_policy() {
let engine = test_engine().with_default_policy(PolicyDecision::Allow);
let tenant = TenantContext::new("test");
let hints = ToolAnnotations::new();
let decision = engine.evaluate("server", "tool", &hints, &tenant, "req-1");
assert!(decision.is_allowed());
}
#[test]
fn test_tool_policy() {
let engine = test_engine().with_tool_policy(
QualifiedToolName::new("server", "dangerous_tool"),
ToolPolicy {
decision: PolicyDecision::Deny,
},
);
let tenant = TenantContext::new("test");
let hints = ToolAnnotations::new();
let decision = engine.evaluate("server", "dangerous_tool", &hints, &tenant, "req-1");
assert!(!decision.is_allowed());
}
#[test]
fn test_server_trust_level() {
let engine = test_engine().with_server_policy(
"trusted_server",
ServerPolicy {
default: PolicyDecision::Allow,
trust_level: TrustLevel::Trusted,
},
);
let tenant = TenantContext::new("test");
let hints = ToolAnnotations::new().with_destructive(true);
let decision = engine.evaluate("trusted_server", "tool", &hints, &tenant, "req-1");
assert!(decision.is_allowed());
}
#[test]
fn test_untrusted_server() {
let engine = test_engine().with_server_policy(
"untrusted",
ServerPolicy {
default: PolicyDecision::Allow,
trust_level: TrustLevel::Untrusted,
},
);
let tenant = TenantContext::new("test");
let destructive = ToolAnnotations::new().with_destructive(true);
let read_only = ToolAnnotations::new().with_read_only(true);
let decision = engine.evaluate("untrusted", "tool", &destructive, &tenant, "req-1");
assert!(!decision.is_allowed());
let decision = engine.evaluate("untrusted", "tool", &read_only, &tenant, "req-2");
assert!(decision.is_allowed());
}
#[test]
fn test_sandboxed_server() {
let engine = test_engine().with_server_policy(
"sandbox",
ServerPolicy {
default: PolicyDecision::Allow,
trust_level: TrustLevel::Sandboxed,
},
);
let tenant = TenantContext::new("test");
let open_world = ToolAnnotations::new().with_open_world(true);
let read_only = ToolAnnotations::new().with_read_only(true);
let decision = engine.evaluate("sandbox", "tool", &open_world, &tenant, "req-1");
assert!(!decision.is_allowed());
let decision = engine.evaluate("sandbox", "tool", &read_only, &tenant, "req-2");
assert!(decision.is_allowed());
}
#[test]
fn test_pattern_rule() {
let engine = test_engine()
.with_default_policy(PolicyDecision::Allow)
.with_rule(PolicyRule::new(
"block_delete",
RulePattern::Tool(Regex::new("^delete_").unwrap()),
RuleCondition::Always,
PolicyDecision::Deny,
));
let tenant = TenantContext::new("test");
let hints = ToolAnnotations::new();
let decision = engine.evaluate("server", "delete_user", &hints, &tenant, "req-1");
assert!(!decision.is_allowed());
let decision = engine.evaluate("server", "get_user", &hints, &tenant, "req-2");
assert!(decision.is_allowed());
}
#[test]
fn test_annotation_based_decision() {
let engine = test_engine().with_default_policy(PolicyDecision::Deny);
let tenant = TenantContext::new("test");
let read_only = ToolAnnotations::new().with_read_only(true);
let decision = engine.evaluate("server", "tool", &read_only, &tenant, "req-1");
assert!(decision.is_allowed());
}
}