use std::path::{Path, PathBuf};
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::SkillTrustLevel;
const MAX_RULES: usize = 256;
const MAX_REGEX_LEN: usize = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PolicyEffect {
Allow,
Deny,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DefaultEffect {
Allow,
#[default]
Deny,
}
fn default_deny() -> DefaultEffect {
DefaultEffect::Deny
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct PolicyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_deny")]
pub default_effect: DefaultEffect,
#[serde(default)]
pub rules: Vec<PolicyRuleConfig>,
pub policy_file: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PolicyRuleConfig {
pub effect: PolicyEffect,
pub tool: String,
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub env: Vec<String>,
pub trust_level: Option<SkillTrustLevel>,
pub args_match: Option<String>,
#[serde(default)]
pub capabilities: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct PolicyContext {
pub trust_level: SkillTrustLevel,
pub env: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub enum PolicyDecision {
Allow { trace: String },
Deny { trace: String },
}
#[derive(Debug, thiserror::Error)]
pub enum PolicyCompileError {
#[error("invalid glob pattern in rule {index}: {source}")]
InvalidGlob {
index: usize,
source: glob::PatternError,
},
#[error("invalid regex in rule {index}: {source}")]
InvalidRegex { index: usize, source: regex::Error },
#[error("regex pattern in rule {index} exceeds maximum length ({MAX_REGEX_LEN} bytes)")]
RegexTooLong { index: usize },
#[error("too many rules: {count} exceeds maximum of {MAX_RULES}")]
TooManyRules { count: usize },
#[error("failed to load policy file {path}: {source}")]
FileLoad {
path: PathBuf,
source: std::io::Error,
},
#[error("policy file too large: {path}")]
FileTooLarge { path: PathBuf },
#[error("policy file escapes project root: {path}")]
FileEscapesRoot { path: PathBuf },
#[error("failed to parse policy file {path}: {source}")]
FileParse {
path: PathBuf,
source: toml::de::Error,
},
}
#[derive(Debug)]
struct CompiledRule {
effect: PolicyEffect,
tool_matcher: glob::Pattern,
path_matchers: Vec<glob::Pattern>,
env_required: Vec<String>,
trust_threshold: Option<SkillTrustLevel>,
args_regex: Option<Regex>,
source_index: usize,
}
impl CompiledRule {
fn matches(
&self,
tool_name: &str,
params: &serde_json::Map<String, serde_json::Value>,
context: &PolicyContext,
) -> bool {
if !self.tool_matcher.matches(tool_name) {
return false;
}
if !self.path_matchers.is_empty() {
let paths = extract_paths(params);
let any_path_matches = paths.iter().any(|p| {
let normalized = crate::file::normalize_path(Path::new(p))
.to_string_lossy()
.into_owned();
self.path_matchers
.iter()
.any(|pat| pat.matches(&normalized))
});
if !any_path_matches {
return false;
}
}
if !self
.env_required
.iter()
.all(|k| context.env.contains_key(k.as_str()))
{
return false;
}
if self
.trust_threshold
.is_some_and(|t| context.trust_level.severity() > t.severity())
{
return false;
}
if let Some(re) = &self.args_regex {
let any_matches = params.values().any(|v| {
if let Some(s) = v.as_str() {
re.is_match(s)
} else {
false
}
});
if !any_matches {
return false;
}
}
true
}
}
#[derive(Debug)]
pub struct PolicyEnforcer {
rules: Vec<CompiledRule>,
default_effect: DefaultEffect,
}
impl PolicyEnforcer {
pub fn compile(config: &PolicyConfig) -> Result<Self, PolicyCompileError> {
let rule_configs: Vec<PolicyRuleConfig> = if let Some(path) = &config.policy_file {
load_policy_file(Path::new(path))?
} else {
config.rules.clone()
};
if rule_configs.len() > MAX_RULES {
return Err(PolicyCompileError::TooManyRules {
count: rule_configs.len(),
});
}
let mut rules = Vec::with_capacity(rule_configs.len());
for (i, rule) in rule_configs.iter().enumerate() {
let normalized_tool =
resolve_tool_alias(rule.tool.trim().to_lowercase().as_str()).to_owned();
let tool_matcher = glob::Pattern::new(&normalized_tool)
.map_err(|source| PolicyCompileError::InvalidGlob { index: i, source })?;
let path_matchers = rule
.paths
.iter()
.map(|p| {
glob::Pattern::new(p)
.map_err(|source| PolicyCompileError::InvalidGlob { index: i, source })
})
.collect::<Result<Vec<_>, _>>()?;
let args_regex = if let Some(pattern) = &rule.args_match {
if pattern.len() > MAX_REGEX_LEN {
return Err(PolicyCompileError::RegexTooLong { index: i });
}
Some(
Regex::new(pattern)
.map_err(|source| PolicyCompileError::InvalidRegex { index: i, source })?,
)
} else {
None
};
rules.push(CompiledRule {
effect: rule.effect,
tool_matcher,
path_matchers,
env_required: rule.env.clone(),
trust_threshold: rule.trust_level,
args_regex,
source_index: i,
});
}
Ok(Self {
rules,
default_effect: config.default_effect,
})
}
#[must_use]
pub fn rule_count(&self) -> usize {
self.rules.len()
}
#[must_use]
pub fn evaluate(
&self,
tool_name: &str,
params: &serde_json::Map<String, serde_json::Value>,
context: &PolicyContext,
) -> PolicyDecision {
let normalized = resolve_tool_alias(tool_name.trim().to_lowercase().as_str()).to_owned();
for rule in &self.rules {
if rule.effect == PolicyEffect::Deny && rule.matches(&normalized, params, context) {
let trace = format!(
"rule[{}] deny: tool={} matched {}",
rule.source_index, tool_name, rule.tool_matcher
);
return PolicyDecision::Deny { trace };
}
}
for rule in &self.rules {
if rule.effect != PolicyEffect::Deny && rule.matches(&normalized, params, context) {
let trace = format!(
"rule[{}] allow: tool={} matched {}",
rule.source_index, tool_name, rule.tool_matcher
);
return PolicyDecision::Allow { trace };
}
}
match self.default_effect {
DefaultEffect::Allow => PolicyDecision::Allow {
trace: "default: allow (no matching rules)".to_owned(),
},
DefaultEffect::Deny => PolicyDecision::Deny {
trace: "default: deny (no matching rules)".to_owned(),
},
}
}
}
fn resolve_tool_alias(name: &str) -> &str {
match name {
"bash" | "sh" => "shell",
other => other,
}
}
fn load_policy_file(path: &Path) -> Result<Vec<PolicyRuleConfig>, PolicyCompileError> {
const MAX_POLICY_FILE_BYTES: u64 = 256 * 1024;
#[derive(Deserialize)]
struct PolicyFile {
#[serde(default)]
rules: Vec<PolicyRuleConfig>,
}
let canonical = std::fs::canonicalize(path).map_err(|source| PolicyCompileError::FileLoad {
path: path.to_owned(),
source,
})?;
let canonical_base = std::env::current_dir()
.and_then(std::fs::canonicalize)
.map_err(|source| PolicyCompileError::FileLoad {
path: path.to_owned(),
source,
})?;
if !canonical.starts_with(&canonical_base) {
tracing::warn!(
path = %canonical.display(),
"policy file escapes project root, rejecting"
);
return Err(PolicyCompileError::FileEscapesRoot {
path: path.to_owned(),
});
}
let meta = std::fs::metadata(&canonical).map_err(|source| PolicyCompileError::FileLoad {
path: path.to_owned(),
source,
})?;
if meta.len() > MAX_POLICY_FILE_BYTES {
return Err(PolicyCompileError::FileTooLarge {
path: path.to_owned(),
});
}
let content =
std::fs::read_to_string(&canonical).map_err(|source| PolicyCompileError::FileLoad {
path: path.to_owned(),
source,
})?;
let parsed: PolicyFile =
toml::from_str(&content).map_err(|source| PolicyCompileError::FileParse {
path: path.to_owned(),
source,
})?;
Ok(parsed.rules)
}
fn extract_paths(params: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
static ABS_PATH_RE: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"(/[^\s;|&<>]+)").expect("valid regex"));
let mut paths = Vec::new();
for key in &["file_path", "path", "uri", "url", "query"] {
if let Some(v) = params.get(*key).and_then(|v| v.as_str()) {
paths.push(v.to_owned());
}
}
if let Some(cmd) = params.get("command").and_then(|v| v.as_str()) {
for cap in ABS_PATH_RE.captures_iter(cmd) {
if let Some(m) = cap.get(1) {
paths.push(m.as_str().to_owned());
}
}
}
paths
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
fn make_context(trust: SkillTrustLevel) -> PolicyContext {
PolicyContext {
trust_level: trust,
env: HashMap::new(),
}
}
fn make_params(key: &str, value: &str) -> serde_json::Map<String, serde_json::Value> {
let mut m = serde_json::Map::new();
m.insert(key.to_owned(), serde_json::Value::String(value.to_owned()));
m
}
fn empty_params() -> serde_json::Map<String, serde_json::Value> {
serde_json::Map::new()
}
#[test]
fn test_path_normalization() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![PolicyRuleConfig {
effect: PolicyEffect::Deny,
tool: "shell".to_owned(),
paths: vec!["/etc/*".to_owned()],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
}],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let params = make_params("file_path", "/tmp/../etc/passwd");
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(
matches!(
enforcer.evaluate("shell", ¶ms, &ctx),
PolicyDecision::Deny { .. }
),
"path traversal must be caught after normalization"
);
}
#[test]
fn test_path_normalization_dot_segments() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![PolicyRuleConfig {
effect: PolicyEffect::Deny,
tool: "shell".to_owned(),
paths: vec!["/etc/*".to_owned()],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
}],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let params = make_params("file_path", "/etc/./shadow");
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(matches!(
enforcer.evaluate("shell", ¶ms, &ctx),
PolicyDecision::Deny { .. }
));
}
#[test]
fn test_tool_name_normalization() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![PolicyRuleConfig {
effect: PolicyEffect::Deny,
tool: "Shell".to_owned(),
paths: vec![],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
}],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(matches!(
enforcer.evaluate("shell", &empty_params(), &ctx),
PolicyDecision::Deny { .. }
));
assert!(matches!(
enforcer.evaluate("SHELL", &empty_params(), &ctx),
PolicyDecision::Deny { .. }
));
}
#[test]
fn test_deny_wins() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![
PolicyRuleConfig {
effect: PolicyEffect::Allow,
tool: "shell".to_owned(),
paths: vec!["/tmp/*".to_owned()],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
},
PolicyRuleConfig {
effect: PolicyEffect::Deny,
tool: "shell".to_owned(),
paths: vec!["/tmp/secret.sh".to_owned()],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
},
],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let params = make_params("file_path", "/tmp/secret.sh");
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(
matches!(
enforcer.evaluate("shell", ¶ms, &ctx),
PolicyDecision::Deny { .. }
),
"deny must win over allow for the same path"
);
}
#[test]
fn deny_wins_deny_first() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![
PolicyRuleConfig {
effect: PolicyEffect::Deny,
tool: "shell".to_owned(),
paths: vec!["/etc/*".to_owned()],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
},
PolicyRuleConfig {
effect: PolicyEffect::Allow,
tool: "shell".to_owned(),
paths: vec!["/etc/*".to_owned()],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
},
],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let params = make_params("file_path", "/etc/passwd");
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(
matches!(
enforcer.evaluate("shell", ¶ms, &ctx),
PolicyDecision::Deny { .. }
),
"deny must win when deny rule is first"
);
}
#[test]
fn deny_wins_deny_last() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![
PolicyRuleConfig {
effect: PolicyEffect::Allow,
tool: "shell".to_owned(),
paths: vec!["/etc/*".to_owned()],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
},
PolicyRuleConfig {
effect: PolicyEffect::Deny,
tool: "shell".to_owned(),
paths: vec!["/etc/*".to_owned()],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
},
],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let params = make_params("file_path", "/etc/passwd");
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(
matches!(
enforcer.evaluate("shell", ¶ms, &ctx),
PolicyDecision::Deny { .. }
),
"deny must win even when deny rule is last"
);
}
#[test]
fn test_default_deny() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Deny,
rules: vec![],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(matches!(
enforcer.evaluate("bash", &empty_params(), &ctx),
PolicyDecision::Deny { .. }
));
}
#[test]
fn test_default_allow() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(matches!(
enforcer.evaluate("bash", &empty_params(), &ctx),
PolicyDecision::Allow { .. }
));
}
#[test]
fn test_trust_level_condition() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Deny,
rules: vec![PolicyRuleConfig {
effect: PolicyEffect::Allow,
tool: "shell".to_owned(),
paths: vec![],
env: vec![],
trust_level: Some(SkillTrustLevel::Verified),
args_match: None,
capabilities: vec![],
}],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let trusted_ctx = make_context(SkillTrustLevel::Trusted);
assert!(
matches!(
enforcer.evaluate("shell", &empty_params(), &trusted_ctx),
PolicyDecision::Allow { .. }
),
"Trusted (severity 0) <= Verified threshold (severity 1) -> Allow"
);
let quarantined_ctx = make_context(SkillTrustLevel::Quarantined);
assert!(
matches!(
enforcer.evaluate("shell", &empty_params(), &quarantined_ctx),
PolicyDecision::Deny { .. }
),
"Quarantined (severity 2) > Verified threshold (severity 1) -> falls through to default deny"
);
}
#[test]
fn test_too_many_rules_rejected() {
let rules: Vec<PolicyRuleConfig> = (0..=MAX_RULES)
.map(|i| PolicyRuleConfig {
effect: PolicyEffect::Allow,
tool: format!("tool_{i}"),
paths: vec![],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
})
.collect();
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Deny,
rules,
policy_file: None,
};
assert!(matches!(
PolicyEnforcer::compile(&config),
Err(PolicyCompileError::TooManyRules { .. })
));
}
#[test]
fn deep_dotdot_traversal_blocked_by_deny_rule() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![PolicyRuleConfig {
effect: PolicyEffect::Deny,
tool: "shell".to_owned(),
paths: vec!["/etc/*".to_owned()],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
}],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let params = make_params("file_path", "/a/b/c/d/../../../../../../etc/passwd");
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(
matches!(
enforcer.evaluate("shell", ¶ms, &ctx),
PolicyDecision::Deny { .. }
),
"deep .. chain traversal to /etc/passwd must be caught"
);
}
#[test]
fn test_args_match_matches_param_value() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![PolicyRuleConfig {
effect: PolicyEffect::Deny,
tool: "bash".to_owned(),
paths: vec![],
env: vec![],
trust_level: None,
args_match: Some(".*sudo.*".to_owned()),
capabilities: vec![],
}],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let ctx = make_context(SkillTrustLevel::Trusted);
let params = make_params("command", "sudo rm -rf /");
assert!(matches!(
enforcer.evaluate("bash", ¶ms, &ctx),
PolicyDecision::Deny { .. }
));
let safe_params = make_params("command", "echo hello");
assert!(matches!(
enforcer.evaluate("bash", &safe_params, &ctx),
PolicyDecision::Allow { .. }
));
}
#[test]
fn policy_config_toml_round_trip() {
let toml_str = r#"
enabled = true
default_effect = "deny"
[[rules]]
effect = "deny"
tool = "shell"
paths = ["/etc/*"]
[[rules]]
effect = "allow"
tool = "shell"
paths = ["/tmp/*"]
trust_level = "verified"
"#;
let config: PolicyConfig = toml::from_str(toml_str).unwrap();
assert!(config.enabled);
assert_eq!(config.default_effect, DefaultEffect::Deny);
assert_eq!(config.rules.len(), 2);
assert_eq!(config.rules[0].effect, PolicyEffect::Deny);
assert_eq!(config.rules[0].paths[0], "/etc/*");
assert_eq!(config.rules[1].trust_level, Some(SkillTrustLevel::Verified));
}
#[test]
fn policy_config_default_is_disabled_deny() {
let config = PolicyConfig::default();
assert!(!config.enabled);
assert_eq!(config.default_effect, DefaultEffect::Deny);
assert!(config.rules.is_empty());
}
#[test]
fn policy_file_loaded_from_cwd_subdir() {
let dir = tempfile::tempdir().unwrap();
let original_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let policy_path = dir.path().join("policy.toml");
std::fs::write(
&policy_path,
r#"[[rules]]
effect = "deny"
tool = "shell"
"#,
)
.unwrap();
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![],
policy_file: Some(policy_path.to_string_lossy().into_owned()),
};
let result = PolicyEnforcer::compile(&config);
std::env::set_current_dir(&original_cwd).unwrap();
assert!(result.is_ok(), "policy file within cwd must be accepted");
}
#[cfg(unix)]
#[test]
fn policy_file_symlink_escaping_project_root_is_rejected() {
use std::os::unix::fs::symlink;
let outside = tempfile::tempdir().unwrap();
let inside = tempfile::tempdir().unwrap();
std::fs::write(
outside.path().join("outside.toml"),
"[[rules]]\neffect = \"deny\"\ntool = \"*\"\n",
)
.unwrap();
let link = inside.path().join("evil.toml");
symlink(outside.path().join("outside.toml"), &link).unwrap();
let original_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(inside.path()).unwrap();
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![],
policy_file: Some(link.to_string_lossy().into_owned()),
};
let result = PolicyEnforcer::compile(&config);
std::env::set_current_dir(&original_cwd).unwrap();
assert!(
matches!(result, Err(PolicyCompileError::FileEscapesRoot { .. })),
"symlink escaping project root must be rejected"
);
}
#[test]
fn alias_shell_rule_matches_bash_tool_id() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![PolicyRuleConfig {
effect: PolicyEffect::Deny,
tool: "shell".to_owned(),
paths: vec![],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
}],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(
matches!(
enforcer.evaluate("bash", &empty_params(), &ctx),
PolicyDecision::Deny { .. }
),
"rule tool='shell' must match runtime tool_id='bash' via alias"
);
}
#[test]
fn alias_bash_rule_matches_bash_tool_id() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![PolicyRuleConfig {
effect: PolicyEffect::Deny,
tool: "bash".to_owned(),
paths: vec![],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
}],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(
matches!(
enforcer.evaluate("bash", &empty_params(), &ctx),
PolicyDecision::Deny { .. }
),
"rule tool='bash' must still match runtime tool_id='bash'"
);
}
#[test]
fn alias_sh_rule_matches_bash_tool_id() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![PolicyRuleConfig {
effect: PolicyEffect::Deny,
tool: "sh".to_owned(),
paths: vec![],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
}],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(
matches!(
enforcer.evaluate("bash", &empty_params(), &ctx),
PolicyDecision::Deny { .. }
),
"rule tool='sh' must match runtime tool_id='bash' via alias"
);
}
#[test]
fn max_rules_exactly_256_compiles() {
let rules: Vec<PolicyRuleConfig> = (0..MAX_RULES)
.map(|i| PolicyRuleConfig {
effect: PolicyEffect::Allow,
tool: format!("tool_{i}"),
paths: vec![],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
})
.collect();
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Deny,
rules,
policy_file: None,
};
assert!(
PolicyEnforcer::compile(&config).is_ok(),
"exactly {MAX_RULES} rules must compile successfully"
);
}
#[test]
fn policy_file_happy_path() {
let cwd = std::env::current_dir().unwrap();
let dir = tempfile::tempdir_in(&cwd).unwrap();
let policy_path = dir.path().join("policy.toml");
std::fs::write(
&policy_path,
"[[rules]]\neffect = \"deny\"\ntool = \"shell\"\npaths = [\"/etc/*\"]\n",
)
.unwrap();
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![],
policy_file: Some(policy_path.to_string_lossy().into_owned()),
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let params = make_params("file_path", "/etc/passwd");
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(
matches!(
enforcer.evaluate("shell", ¶ms, &ctx),
PolicyDecision::Deny { .. }
),
"deny rule loaded from file must block the matching call"
);
}
#[test]
fn policy_file_too_large() {
let cwd = std::env::current_dir().unwrap();
let dir = tempfile::tempdir_in(&cwd).unwrap();
let policy_path = dir.path().join("big.toml");
std::fs::write(&policy_path, vec![b'x'; 256 * 1024 + 1]).unwrap();
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![],
policy_file: Some(policy_path.to_string_lossy().into_owned()),
};
assert!(
matches!(
PolicyEnforcer::compile(&config),
Err(PolicyCompileError::FileTooLarge { .. })
),
"file exceeding 256 KiB must return FileTooLarge"
);
}
#[test]
fn policy_file_load_error() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![],
policy_file: Some("/tmp/__zeph_no_such_policy_file__.toml".to_owned()),
};
assert!(
matches!(
PolicyEnforcer::compile(&config),
Err(PolicyCompileError::FileLoad { .. })
),
"nonexistent policy file must return FileLoad"
);
}
#[test]
fn policy_file_parse_error() {
let cwd = std::env::current_dir().unwrap();
let dir = tempfile::tempdir_in(&cwd).unwrap();
let policy_path = dir.path().join("bad.toml");
std::fs::write(&policy_path, "not valid toml = = =\n[[[\n").unwrap();
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![],
policy_file: Some(policy_path.to_string_lossy().into_owned()),
};
assert!(
matches!(
PolicyEnforcer::compile(&config),
Err(PolicyCompileError::FileParse { .. })
),
"malformed TOML must return FileParse"
);
}
#[test]
fn alias_unknown_tool_unaffected() {
let config = PolicyConfig {
enabled: true,
default_effect: DefaultEffect::Allow,
rules: vec![PolicyRuleConfig {
effect: PolicyEffect::Deny,
tool: "shell".to_owned(),
paths: vec![],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
}],
policy_file: None,
};
let enforcer = PolicyEnforcer::compile(&config).unwrap();
let ctx = make_context(SkillTrustLevel::Trusted);
assert!(
matches!(
enforcer.evaluate("web_scrape", &empty_params(), &ctx),
PolicyDecision::Allow { .. }
),
"unknown tool names must not be affected by alias resolution"
);
}
}