use crate::{PolicyCheck, PolicyEngine};
use car_ir::Action;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct DenyToolParam {
pub tool: String,
pub param: String,
#[serde(default)]
pub equals: Option<Value>,
#[serde(default)]
pub contains: Option<String>,
}
impl DenyToolParam {
fn matches(&self, action: &Action) -> bool {
if action.tool.as_deref() != Some(self.tool.as_str()) {
return false;
}
let Some(val) = action.parameters.get(&self.param) else {
return false; };
if self.equals.is_none() && self.contains.is_none() {
return true;
}
let mut ok = true;
if let Some(expected) = &self.equals {
ok &= val == expected;
}
if let Some(needle) = &self.contains {
let hay = match val {
Value::String(s) => s.clone(),
other => other.to_string(),
};
ok &= hay.contains(needle);
}
ok
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
pub struct PolicyRules {
#[serde(default)]
pub deny_tool: Vec<String>,
#[serde(default)]
pub deny_keyword: Vec<String>,
#[serde(default)]
pub deny_tool_param: Vec<DenyToolParam>,
}
impl PolicyRules {
pub fn merge(&mut self, other: PolicyRules) {
self.deny_tool.extend(other.deny_tool);
self.deny_keyword.extend(other.deny_keyword);
self.deny_tool_param.extend(other.deny_tool_param);
}
pub fn is_empty(&self) -> bool {
self.deny_tool.is_empty() && self.deny_keyword.is_empty() && self.deny_tool_param.is_empty()
}
pub fn from_toml(src: &str) -> Result<PolicyRules, PolicyLoadError> {
toml::from_str(src).map_err(|e| PolicyLoadError::Parse {
path: None,
message: e.to_string(),
})
}
pub fn apply(&self, engine: &mut PolicyEngine) {
for tool in &self.deny_tool {
let tool = tool.clone();
let name = format!("deny_tool:{tool}");
let desc = format!("project .car/policies deny_tool: {tool}");
let check: PolicyCheck = Box::new(move |action: &Action, _state| {
if action.tool.as_deref() == Some(tool.as_str()) {
Some(format!("tool '{tool}' is denied by project policy"))
} else {
None
}
});
engine.register(&name, check, &desc);
}
for kw in &self.deny_keyword {
let kw = kw.clone();
let name = format!("deny_keyword:{kw}");
let desc = format!("project .car/policies deny_keyword: {kw}");
let check: PolicyCheck = Box::new(move |action: &Action, _state| {
for (k, v) in &action.parameters {
let hay = match v {
Value::String(s) => s.clone(),
other => other.to_string(),
};
if hay.contains(&kw) {
return Some(format!(
"parameter '{k}' contains denied keyword '{kw}'"
));
}
}
None
});
engine.register(&name, check, &desc);
}
for rule in &self.deny_tool_param {
let rule = rule.clone();
let name = format!("deny_tool_param:{}.{}", rule.tool, rule.param);
let desc = format!(
"project .car/policies deny_tool_param on {}.{}",
rule.tool, rule.param
);
let check: PolicyCheck = Box::new(move |action: &Action, _state| {
if rule.matches(action) {
Some(format!(
"tool '{}' parameter '{}' is denied by project policy",
rule.tool, rule.param
))
} else {
None
}
});
engine.register(&name, check, &desc);
}
}
}
pub fn load_policy_dir(dir: impl AsRef<Path>) -> Result<PolicyRules, PolicyLoadError> {
let dir = dir.as_ref();
if !dir.exists() {
return Ok(PolicyRules::default());
}
let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
.map_err(|e| PolicyLoadError::Io {
path: dir.to_path_buf(),
message: e.to_string(),
})?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("toml"))
.collect();
files.sort();
let mut merged = PolicyRules::default();
for path in files {
let src = std::fs::read_to_string(&path).map_err(|e| PolicyLoadError::Io {
path: path.clone(),
message: e.to_string(),
})?;
let rules = PolicyRules::from_toml(&src).map_err(|e| match e {
PolicyLoadError::Parse { message, .. } => PolicyLoadError::Parse {
path: Some(path.clone()),
message,
},
other => other,
})?;
merged.merge(rules);
}
Ok(merged)
}
#[derive(Debug, Clone)]
pub enum PolicyLoadError {
Io { path: PathBuf, message: String },
Parse {
path: Option<PathBuf>,
message: String,
},
}
impl fmt::Display for PolicyLoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PolicyLoadError::Io { path, message } => {
write!(f, "policy I/O error at {}: {message}", path.display())
}
PolicyLoadError::Parse { path, message } => match path {
Some(p) => write!(f, "policy parse error in {}: {message}", p.display()),
None => write!(f, "policy parse error: {message}"),
},
}
}
}
impl std::error::Error for PolicyLoadError {}
#[cfg(test)]
mod tests {
use super::*;
use car_ir::{Action, ActionType, FailureBehavior};
use car_state::StateStore;
use std::collections::HashMap;
fn tool_action(tool: &str, params: HashMap<String, Value>) -> Action {
Action {
id: "a1".to_string(),
action_type: ActionType::ToolCall,
tool: Some(tool.to_string()),
parameters: params,
preconditions: vec![],
expected_effects: HashMap::new(),
state_dependencies: vec![],
read_set: vec![],
write_set: vec![],
assumptions: vec![],
invocation_mode: Default::default(),
idempotent: false,
max_retries: 0,
failure_behavior: FailureBehavior::Abort,
timeout_ms: None,
metadata: HashMap::new(),
}
}
#[test]
fn parses_full_document() {
let src = r#"
deny_tool = ["deploy", "rm"]
deny_keyword = ["DROP TABLE"]
[[deny_tool_param]]
tool = "http_request"
param = "url"
contains = "169.254.169.254"
"#;
let rules = PolicyRules::from_toml(src).unwrap();
assert_eq!(rules.deny_tool, vec!["deploy", "rm"]);
assert_eq!(rules.deny_keyword, vec!["DROP TABLE"]);
assert_eq!(rules.deny_tool_param.len(), 1);
assert_eq!(rules.deny_tool_param[0].tool, "http_request");
}
#[test]
fn deny_tool_blocks_named_tool() {
let mut engine = PolicyEngine::new();
PolicyRules {
deny_tool: vec!["deploy".to_string()],
..Default::default()
}
.apply(&mut engine);
let state = StateStore::new();
let v = engine.check(&tool_action("deploy", HashMap::new()), &state);
assert_eq!(v.len(), 1);
assert!(v[0].reason.contains("denied by project policy"));
assert!(engine
.check(&tool_action("echo", HashMap::new()), &state)
.is_empty());
}
#[test]
fn deny_keyword_scans_params() {
let mut engine = PolicyEngine::new();
PolicyRules {
deny_keyword: vec!["rm -rf /".to_string()],
..Default::default()
}
.apply(&mut engine);
let state = StateStore::new();
let params = [("command".to_string(), Value::from("sudo rm -rf / now"))].into();
let v = engine.check(&tool_action("shell", params), &state);
assert_eq!(v.len(), 1);
assert!(v[0].reason.contains("denied keyword"));
}
#[test]
fn deny_tool_param_contains_and_equals() {
let mut engine = PolicyEngine::new();
PolicyRules {
deny_tool_param: vec![
DenyToolParam {
tool: "http_request".to_string(),
param: "url".to_string(),
equals: None,
contains: Some("metadata".to_string()),
},
DenyToolParam {
tool: "shell".to_string(),
param: "command".to_string(),
equals: Some(Value::from("shutdown")),
contains: None,
},
],
..Default::default()
}
.apply(&mut engine);
let state = StateStore::new();
let p1 = [("url".to_string(), Value::from("http://metadata.local"))].into();
assert_eq!(engine.check(&tool_action("http_request", p1), &state).len(), 1);
let p2 = [("url".to_string(), Value::from("http://example.com"))].into();
assert!(engine.check(&tool_action("http_request", p2), &state).is_empty());
let p3 = [("command".to_string(), Value::from("shutdown"))].into();
assert_eq!(engine.check(&tool_action("shell", p3), &state).len(), 1);
let p4 = [("command".to_string(), Value::from("ls"))].into();
assert!(engine.check(&tool_action("shell", p4), &state).is_empty());
let p5 = [("command".to_string(), Value::from("shutdown"))].into();
assert!(engine.check(&tool_action("other", p5), &state).is_empty());
}
#[test]
fn missing_dir_is_empty_not_error() {
let rules = load_policy_dir("/nonexistent/.car/policies").unwrap();
assert!(rules.is_empty());
}
#[test]
fn malformed_file_is_loud_error() {
let dir = std::env::temp_dir().join(format!("car_pol_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("bad.toml"), "deny_tool = [unclosed").unwrap();
let err = load_policy_dir(&dir).unwrap_err();
assert!(matches!(err, PolicyLoadError::Parse { .. }));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn loads_and_merges_multiple_files() {
let dir = std::env::temp_dir().join(format!("car_pol_merge_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.toml"), "deny_tool = [\"x\"]").unwrap();
std::fs::write(dir.join("b.toml"), "deny_tool = [\"y\"]").unwrap();
let rules = load_policy_dir(&dir).unwrap();
assert!(rules.deny_tool.contains(&"x".to_string()));
assert!(rules.deny_tool.contains(&"y".to_string()));
std::fs::remove_dir_all(&dir).ok();
}
}