use crate::{
decision::{BlockCode, Decision},
error::GuardrailError,
pipeline::Stage,
request::GuardrailRequest,
};
#[derive(Debug, Clone)]
pub struct PolicyRule {
pub name: String,
pub enabled: bool,
pub condition: PolicyCondition,
pub action: PolicyAction,
pub message: Option<String>,
}
#[derive(Debug, Clone)]
pub enum PolicyCondition {
ContentContains(Vec<String>),
SystemPromptAbsent,
TokenCountExceeds(usize),
Always,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyAction {
Allow,
Redact,
Block,
LogOnly,
}
pub struct PolicyEngine {
rules: Vec<PolicyRule>,
}
impl PolicyEngine {
pub fn new(rules: Vec<PolicyRule>) -> Self {
Self { rules }
}
pub fn empty() -> Self {
Self { rules: Vec::new() }
}
}
#[async_trait::async_trait]
impl Stage for PolicyEngine {
fn name(&self) -> &'static str {
"policy_engine"
}
async fn evaluate(&self, req: &GuardrailRequest) -> Result<Decision, GuardrailError> {
for rule in &self.rules {
if !rule.enabled {
continue;
}
let matched = match &rule.condition {
PolicyCondition::ContentContains(keywords) => {
let all_text = req.all_text().to_lowercase();
keywords.iter().any(|kw| all_text.contains(kw.as_str()))
}
PolicyCondition::SystemPromptAbsent => !req
.messages
.iter()
.any(|m| matches!(m.role, crate::request::Role::System)),
PolicyCondition::TokenCountExceeds(limit) => {
let approx_tokens =
(req.all_text().split_whitespace().count() as f64 * 1.3) as usize;
approx_tokens > *limit
}
PolicyCondition::Always => true,
};
if matched {
let reason = rule
.message
.clone()
.unwrap_or_else(|| format!("Policy rule '{}' matched.", rule.name));
tracing::info!(rule = %rule.name, action = ?rule.action, "policy rule matched");
return Ok(match rule.action {
PolicyAction::Allow | PolicyAction::Redact | PolicyAction::LogOnly => {
Decision::Allow
}
PolicyAction::Block => Decision::Block {
reason,
code: BlockCode::PolicyViolation,
},
});
}
}
Ok(Decision::Allow)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
request::{ChatMessage, MessageContent, Provider, Role},
test_helpers::clean_request,
};
fn make_req_with_content(content: &str) -> GuardrailRequest {
GuardrailRequest::new(
vec![ChatMessage {
role: Role::User,
content: MessageContent::Text(content.into()),
}],
"gpt-4o".into(),
Provider::OpenAI,
)
}
#[tokio::test]
async fn test_no_rules_allows() {
let engine = PolicyEngine::empty();
let req = clean_request();
let decision = engine.evaluate(&req).await.unwrap();
assert_eq!(decision, Decision::Allow);
}
#[tokio::test]
async fn test_content_contains_blocks() {
let engine = PolicyEngine::new(vec![PolicyRule {
name: "no-competitors".into(),
enabled: true,
condition: PolicyCondition::ContentContains(vec!["competitor-x".into()]),
action: PolicyAction::Block,
message: Some("Competitor mentions are not allowed.".into()),
}]);
let req = make_req_with_content("Tell me about Competitor-X products.");
let decision = engine.evaluate(&req).await.unwrap();
assert!(matches!(decision, Decision::Block { .. }));
}
#[tokio::test]
async fn test_disabled_rule_ignored() {
let engine = PolicyEngine::new(vec![PolicyRule {
name: "disabled-rule".into(),
enabled: false,
condition: PolicyCondition::Always,
action: PolicyAction::Block,
message: None,
}]);
let req = clean_request();
let decision = engine.evaluate(&req).await.unwrap();
assert_eq!(decision, Decision::Allow);
}
#[tokio::test]
async fn test_system_prompt_absent_blocks() {
let engine = PolicyEngine::new(vec![PolicyRule {
name: "require-system-prompt".into(),
enabled: true,
condition: PolicyCondition::SystemPromptAbsent,
action: PolicyAction::Block,
message: Some("A system prompt is required.".into()),
}]);
let req = make_req_with_content("Hello!");
let decision = engine.evaluate(&req).await.unwrap();
assert!(matches!(decision, Decision::Block { .. }));
}
#[tokio::test]
async fn test_token_count_blocks() {
let engine = PolicyEngine::new(vec![PolicyRule {
name: "max-tokens".into(),
enabled: true,
condition: PolicyCondition::TokenCountExceeds(5),
action: PolicyAction::Block,
message: Some("Input too long.".into()),
}]);
let long_text = "one two three four five six seven eight".repeat(3);
let req = make_req_with_content(&long_text);
let decision = engine.evaluate(&req).await.unwrap();
assert!(matches!(decision, Decision::Block { .. }));
}
}