use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::types::{GitOperation, PathPattern, ToolCategory};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum EnforcementMode {
#[default]
Coercive,
Normative,
Adaptive,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum PolicyAction {
#[default]
Allow,
Deny,
RequireApproval,
AllowWithAudit,
DenyWithMessage(String),
Escalate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PolicyCondition {
Tool(String),
ToolCategory(ToolCategory),
FilePath(String),
MinTrustLevel(u8),
Domain(String),
GitOp(GitOperation),
TimeRange {
start_hour: u8,
end_hour: u8,
},
And(Vec<PolicyCondition>),
Or(Vec<PolicyCondition>),
Not(Box<PolicyCondition>),
Always,
}
impl PolicyCondition {
pub fn matches(&self, request: &PolicyRequest) -> bool {
match self {
PolicyCondition::Tool(name) => request.tool_name.as_ref() == Some(name),
PolicyCondition::ToolCategory(cat) => request.tool_category.as_ref() == Some(cat),
PolicyCondition::FilePath(pattern) => {
if let Some(path) = &request.file_path {
let pat = PathPattern::new(pattern);
pat.matches(path)
} else {
false
}
}
PolicyCondition::MinTrustLevel(level) => request.trust_level >= *level,
PolicyCondition::Domain(pattern) => {
if let Some(domain) = &request.domain {
if pattern.starts_with("*.") {
let suffix = &pattern[1..]; domain.ends_with(suffix) || domain == &pattern[2..]
} else {
domain == pattern
}
} else {
false
}
}
PolicyCondition::GitOp(op) => request.git_operation.as_ref() == Some(op),
PolicyCondition::TimeRange {
start_hour,
end_hour,
} => {
let hour = chrono::Local::now().hour() as u8;
if start_hour <= end_hour {
hour >= *start_hour && hour < *end_hour
} else {
hour >= *start_hour || hour < *end_hour
}
}
PolicyCondition::And(conditions) => conditions.iter().all(|c| c.matches(request)),
PolicyCondition::Or(conditions) => conditions.iter().any(|c| c.matches(request)),
PolicyCondition::Not(condition) => !condition.matches(request),
PolicyCondition::Always => true,
}
}
}
use chrono::Timelike;
#[derive(Debug, Clone, Default)]
pub struct PolicyRequest {
pub tool_name: Option<String>,
pub tool_category: Option<ToolCategory>,
pub file_path: Option<String>,
pub domain: Option<String>,
pub git_operation: Option<GitOperation>,
pub trust_level: u8,
pub agent_id: Option<String>,
pub metadata: HashMap<String, String>,
}
impl PolicyRequest {
pub fn new() -> Self {
Self::default()
}
pub fn for_tool(tool_name: &str) -> Self {
let category = super::AgentCapabilities::categorize_tool(tool_name);
Self {
tool_name: Some(tool_name.to_string()),
tool_category: Some(category),
..Default::default()
}
}
pub fn for_file(path: &str, tool_name: &str) -> Self {
let category = super::AgentCapabilities::categorize_tool(tool_name);
Self {
tool_name: Some(tool_name.to_string()),
tool_category: Some(category),
file_path: Some(path.to_string()),
..Default::default()
}
}
pub fn for_network(domain: &str) -> Self {
Self {
domain: Some(domain.to_string()),
tool_category: Some(ToolCategory::Web),
..Default::default()
}
}
pub fn for_git(operation: GitOperation) -> Self {
Self {
git_operation: Some(operation),
tool_category: Some(ToolCategory::Git),
..Default::default()
}
}
pub fn with_trust_level(mut self, level: u8) -> Self {
self.trust_level = level;
self
}
pub fn with_agent_id(mut self, id: &str) -> Self {
self.agent_id = Some(id.to_string());
self
}
}
#[derive(Debug, Clone)]
pub struct PolicyDecision {
pub action: PolicyAction,
pub matched_policy: Option<String>,
pub reason: Option<String>,
pub audit: bool,
}
impl PolicyDecision {
pub fn allow() -> Self {
Self {
action: PolicyAction::Allow,
matched_policy: None,
reason: None,
audit: false,
}
}
pub fn deny(reason: &str) -> Self {
Self {
action: PolicyAction::Deny,
matched_policy: None,
reason: Some(reason.to_string()),
audit: true,
}
}
pub fn is_allowed(&self) -> bool {
matches!(
self.action,
PolicyAction::Allow | PolicyAction::AllowWithAudit
)
}
pub fn requires_approval(&self) -> bool {
matches!(self.action, PolicyAction::RequireApproval)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Policy {
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default = "default_priority")]
pub priority: i32,
pub conditions: Vec<PolicyCondition>,
pub action: PolicyAction,
#[serde(default)]
pub enforcement: EnforcementMode,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_priority() -> i32 {
50
}
fn default_true() -> bool {
true
}
impl Policy {
pub fn new(id: &str) -> Self {
Self {
id: id.to_string(),
name: id.to_string(),
description: String::new(),
priority: 50,
conditions: Vec::new(),
action: PolicyAction::Allow,
enforcement: EnforcementMode::Coercive,
enabled: true,
}
}
pub fn with_name(mut self, name: &str) -> Self {
self.name = name.to_string();
self
}
pub fn with_description(mut self, desc: &str) -> Self {
self.description = desc.to_string();
self
}
pub fn with_condition(mut self, condition: PolicyCondition) -> Self {
self.conditions.push(condition);
self
}
pub fn with_action(mut self, action: PolicyAction) -> Self {
self.action = action;
self
}
pub fn with_priority(mut self, priority: i32) -> Self {
self.priority = priority;
self
}
pub fn with_enforcement(mut self, mode: EnforcementMode) -> Self {
self.enforcement = mode;
self
}
pub fn matches(&self, request: &PolicyRequest) -> bool {
if !self.enabled {
return false;
}
if self.conditions.is_empty() {
return false; }
self.conditions.iter().all(|c| c.matches(request))
}
}
#[derive(Debug, Clone, Default)]
pub struct PolicyEngine {
policies: Vec<Policy>,
default_action: PolicyAction,
}
impl PolicyEngine {
pub fn new() -> Self {
Self {
policies: Vec::new(),
default_action: PolicyAction::Allow,
}
}
pub fn with_defaults() -> Self {
let mut engine = Self::new();
engine.add_policy(
Policy::new("protect_env_files")
.with_name("Protect Environment Files")
.with_description("Deny access to .env files which may contain secrets")
.with_condition(PolicyCondition::FilePath("**/.env*".into()))
.with_action(PolicyAction::Deny)
.with_priority(100),
);
engine.add_policy(
Policy::new("protect_secrets")
.with_name("Protect Secret Files")
.with_description("Deny access to files containing 'secret' in the path")
.with_condition(PolicyCondition::FilePath("**/*secret*".into()))
.with_action(PolicyAction::DenyWithMessage(
"Access to secret files is not permitted".into(),
))
.with_priority(100),
);
engine.add_policy(
Policy::new("protect_credentials")
.with_name("Protect Credential Files")
.with_description("Deny access to credential files")
.with_condition(PolicyCondition::FilePath("**/credentials*".into()))
.with_action(PolicyAction::Deny)
.with_priority(100),
);
engine.add_policy(
Policy::new("approve_git_reset")
.with_name("Approve Git Reset")
.with_description("Require approval for git reset operations")
.with_condition(PolicyCondition::GitOp(GitOperation::Reset))
.with_action(PolicyAction::RequireApproval)
.with_priority(90),
);
engine.add_policy(
Policy::new("approve_git_rebase")
.with_name("Approve Git Rebase")
.with_description("Require approval for git rebase operations")
.with_condition(PolicyCondition::GitOp(GitOperation::Rebase))
.with_action(PolicyAction::RequireApproval)
.with_priority(90),
);
engine.add_policy(
Policy::new("audit_bash")
.with_name("Audit Bash Commands")
.with_description("Log all bash command executions")
.with_condition(PolicyCondition::ToolCategory(ToolCategory::Bash))
.with_action(PolicyAction::AllowWithAudit)
.with_priority(10),
);
engine
}
pub fn set_default_action(&mut self, action: PolicyAction) {
self.default_action = action;
}
pub fn add_policy(&mut self, policy: Policy) {
self.policies.push(policy);
self.policies.sort_by(|a, b| b.priority.cmp(&a.priority));
}
pub fn remove_policy(&mut self, id: &str) -> Option<Policy> {
if let Some(pos) = self.policies.iter().position(|p| p.id == id) {
Some(self.policies.remove(pos))
} else {
None
}
}
pub fn get_policy(&self, id: &str) -> Option<&Policy> {
self.policies.iter().find(|p| p.id == id)
}
pub fn policies(&self) -> &[Policy] {
&self.policies
}
pub fn evaluate(&self, request: &PolicyRequest) -> PolicyDecision {
for policy in &self.policies {
if policy.matches(request) {
return PolicyDecision {
action: policy.action.clone(),
matched_policy: Some(policy.id.clone()),
reason: if policy.description.is_empty() {
None
} else {
Some(policy.description.clone())
},
audit: matches!(
policy.action,
PolicyAction::AllowWithAudit
| PolicyAction::Deny
| PolicyAction::DenyWithMessage(_)
| PolicyAction::RequireApproval
),
};
}
}
PolicyDecision {
action: self.default_action.clone(),
matched_policy: None,
reason: None,
audit: false,
}
}
pub fn load_from_config(config: &super::config::PermissionsConfig) -> Self {
let mut engine = Self::new();
for rule in &config.policies.rules {
if let Some(policy) = Self::parse_policy_rule(rule) {
engine.add_policy(policy);
}
}
engine
}
fn parse_policy_rule(rule: &super::config::PolicyRuleConfig) -> Option<Policy> {
let mut policy = Policy::new(&rule.name)
.with_name(&rule.name)
.with_priority(rule.priority as i32);
for condition in rule.get_conditions() {
if let Some(cond) = Self::parse_condition(&condition) {
policy = policy.with_condition(cond);
}
}
let action = match rule.action.to_lowercase().as_str() {
"allow" => PolicyAction::Allow,
"deny" => PolicyAction::Deny,
"requireapproval" | "require_approval" => PolicyAction::RequireApproval,
"allowwithaudit" | "allow_with_audit" => PolicyAction::AllowWithAudit,
"escalate" => PolicyAction::Escalate,
_ => {
if rule.action.starts_with("DenyWithMessage:") {
PolicyAction::DenyWithMessage(rule.action[16..].to_string())
} else {
PolicyAction::Deny
}
}
};
policy = policy.with_action(action);
let mode = match rule.enforcement.to_lowercase().as_str() {
"coercive" => EnforcementMode::Coercive,
"normative" => EnforcementMode::Normative,
"adaptive" => EnforcementMode::Adaptive,
_ => EnforcementMode::Coercive,
};
policy = policy.with_enforcement(mode);
Some(policy)
}
fn parse_condition(condition: &super::config::PolicyCondition) -> Option<PolicyCondition> {
if let Some(tool) = &condition.tool {
return Some(PolicyCondition::Tool(tool.clone()));
}
if let Some(category) = &condition.tool_category
&& let Some(cat) = super::config::parse_tool_category(category)
{
return Some(PolicyCondition::ToolCategory(cat));
}
if let Some(path) = &condition.file_path {
return Some(PolicyCondition::FilePath(path.clone()));
}
if let Some(domain) = &condition.domain {
return Some(PolicyCondition::Domain(domain.clone()));
}
if let Some(git_op) = &condition.git_op
&& let Some(op) = super::config::parse_git_operation(git_op)
{
return Some(PolicyCondition::GitOp(op));
}
if let Some(trust) = condition.min_trust_level {
return Some(PolicyCondition::MinTrustLevel(trust));
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_policy_matching() {
let policy = Policy::new("test")
.with_condition(PolicyCondition::Tool("write_file".into()))
.with_action(PolicyAction::Deny);
let request = PolicyRequest::for_tool("write_file");
assert!(policy.matches(&request));
let request2 = PolicyRequest::for_tool("read_file");
assert!(!policy.matches(&request2));
}
#[test]
fn test_file_path_condition() {
let policy = Policy::new("test")
.with_condition(PolicyCondition::FilePath("**/.env*".into()))
.with_action(PolicyAction::Deny);
let request = PolicyRequest::for_file(".env", "read_file");
assert!(policy.matches(&request));
let request2 = PolicyRequest::for_file(".env.local", "read_file");
assert!(policy.matches(&request2));
let request3 = PolicyRequest::for_file("src/main.rs", "read_file");
assert!(!policy.matches(&request3));
}
#[test]
fn test_domain_condition() {
let cond = PolicyCondition::Domain("*.github.com".into());
let request = PolicyRequest::for_network("api.github.com");
assert!(cond.matches(&request));
let request2 = PolicyRequest::for_network("github.com");
assert!(cond.matches(&request2));
let request3 = PolicyRequest::for_network("evil.com");
assert!(!cond.matches(&request3));
}
#[test]
fn test_compound_conditions() {
let cond = PolicyCondition::And(vec![
PolicyCondition::ToolCategory(ToolCategory::FileWrite),
PolicyCondition::FilePath("**/test/**".into()),
]);
let mut request = PolicyRequest::for_file("src/test/file.rs", "write_file");
request.tool_category = Some(ToolCategory::FileWrite);
assert!(cond.matches(&request));
let mut request2 = PolicyRequest::for_file("src/main.rs", "write_file");
request2.tool_category = Some(ToolCategory::FileWrite);
assert!(!cond.matches(&request2));
}
#[test]
fn test_policy_engine_evaluation() {
let mut engine = PolicyEngine::new();
engine.add_policy(
Policy::new("deny_secrets")
.with_condition(PolicyCondition::FilePath("**/.env*".into()))
.with_action(PolicyAction::Deny)
.with_priority(100),
);
engine.add_policy(
Policy::new("allow_read")
.with_condition(PolicyCondition::ToolCategory(ToolCategory::FileRead))
.with_action(PolicyAction::Allow)
.with_priority(10),
);
let request = PolicyRequest::for_file(".env", "read_file");
let decision = engine.evaluate(&request);
assert!(!decision.is_allowed());
assert_eq!(decision.matched_policy, Some("deny_secrets".to_string()));
let request2 = PolicyRequest::for_file("src/main.rs", "read_file");
let mut request2 = request2;
request2.tool_category = Some(ToolCategory::FileRead);
let decision2 = engine.evaluate(&request2);
assert!(decision2.is_allowed());
}
#[test]
fn test_trust_level_condition() {
let policy = Policy::new("require_trust")
.with_condition(PolicyCondition::MinTrustLevel(2))
.with_action(PolicyAction::Allow);
let low_trust = PolicyRequest::new().with_trust_level(1);
assert!(!policy.matches(&low_trust));
let high_trust = PolicyRequest::new().with_trust_level(3);
assert!(policy.matches(&high_trust));
}
#[test]
fn test_git_operation_condition() {
let policy = Policy::new("approve_reset")
.with_condition(PolicyCondition::GitOp(GitOperation::Reset))
.with_action(PolicyAction::RequireApproval);
let request = PolicyRequest::for_git(GitOperation::Reset);
assert!(policy.matches(&request));
let request2 = PolicyRequest::for_git(GitOperation::Commit);
assert!(!policy.matches(&request2));
}
#[test]
fn test_default_policies() {
let engine = PolicyEngine::with_defaults();
let request = PolicyRequest::for_file(".env", "read_file");
let decision = engine.evaluate(&request);
assert!(!decision.is_allowed());
let request2 = PolicyRequest::for_git(GitOperation::Reset);
let decision2 = engine.evaluate(&request2);
assert!(decision2.requires_approval());
}
#[test]
fn test_not_condition() {
let cond = PolicyCondition::Not(Box::new(PolicyCondition::Tool("read_file".into())));
let request = PolicyRequest::for_tool("write_file");
assert!(cond.matches(&request));
let request2 = PolicyRequest::for_tool("read_file");
assert!(!cond.matches(&request2));
}
#[test]
fn test_or_condition() {
let cond = PolicyCondition::Or(vec![
PolicyCondition::Tool("write_file".into()),
PolicyCondition::Tool("delete_file".into()),
]);
let request = PolicyRequest::for_tool("write_file");
assert!(cond.matches(&request));
let request2 = PolicyRequest::for_tool("delete_file");
assert!(cond.matches(&request2));
let request3 = PolicyRequest::for_tool("read_file");
assert!(!cond.matches(&request3));
}
}