use crate::{PolicyCheck, PolicyEngine};
use car_ir::Action;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::VecDeque;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
fn param_string(val: &Value) -> String {
match val {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
#[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 {
ok &= param_string(val).contains(needle);
}
ok
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct AllowToolParam {
pub tool: String,
pub param: String,
#[serde(default)]
pub allow: Vec<String>,
}
impl AllowToolParam {
fn denies(&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 true; };
!self.allow.contains(¶m_string(val))
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct DenyToolParamMatching {
pub tool: String,
pub param: String,
pub matches: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct RateLimitTool {
pub tool: String,
pub max_calls: u32,
pub interval_secs: f64,
}
#[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>,
#[serde(default)]
pub allow_tool_param: Vec<AllowToolParam>,
#[serde(default)]
pub deny_tool_param_matching: Vec<DenyToolParamMatching>,
#[serde(default)]
pub rate_limit_tool: Vec<RateLimitTool>,
#[serde(default)]
pub trace_rule: Vec<car_verify::trace_policy::TraceRule>,
}
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);
self.allow_tool_param.extend(other.allow_tool_param);
self.deny_tool_param_matching
.extend(other.deny_tool_param_matching);
self.rate_limit_tool.extend(other.rate_limit_tool);
self.trace_rule.extend(other.trace_rule);
}
pub fn is_empty(&self) -> bool {
self.deny_tool.is_empty()
&& self.deny_keyword.is_empty()
&& self.deny_tool_param.is_empty()
&& self.allow_tool_param.is_empty()
&& self.deny_tool_param_matching.is_empty()
&& self.rate_limit_tool.is_empty()
&& self.trace_rule.is_empty()
}
pub fn len(&self) -> usize {
self.deny_tool.len()
+ self.deny_keyword.len()
+ self.deny_tool_param.len()
+ self.allow_tool_param.len()
+ self.deny_tool_param_matching.len()
+ self.rate_limit_tool.len()
+ self.trace_rule.len()
}
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 {
if param_string(v).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);
}
for rule in &self.allow_tool_param {
let rule = rule.clone();
let name = format!("allow_tool_param:{}.{}", rule.tool, rule.param);
let desc = format!(
"project .car/policies allow_tool_param on {}.{}",
rule.tool, rule.param
);
let check: PolicyCheck = Box::new(move |action: &Action, _state| {
if rule.denies(action) {
Some(format!(
"tool '{}' parameter '{}' is not allowlisted by project policy",
rule.tool, rule.param
))
} else {
None
}
});
engine.register(&name, check, &desc);
}
for rule in &self.deny_tool_param_matching {
let tool = rule.tool.clone();
let param = rule.param.clone();
let name = format!("deny_tool_param_matching:{tool}.{param}");
let desc = format!("project .car/policies deny_tool_param_matching on {tool}.{param}");
let check: PolicyCheck = match Regex::new(&rule.matches) {
Ok(re) => Box::new(move |action: &Action, _state| {
if action.tool.as_deref() != Some(tool.as_str()) {
return None;
}
let val = action.parameters.get(¶m)?;
if re.is_match(¶m_string(val)) {
Some(format!(
"tool '{tool}' parameter '{param}' matched a denied pattern"
))
} else {
None
}
}),
Err(e) => {
let err = e.to_string();
Box::new(move |action: &Action, _state| {
if action.tool.as_deref() == Some(tool.as_str()) {
Some(format!(
"tool '{tool}' is denied: the project policy pattern for \
parameter '{param}' failed to compile: {err}"
))
} else {
None
}
})
}
};
engine.register(&name, check, &desc);
}
for rule in &self.rate_limit_tool {
let tool = rule.tool.clone();
let max_calls = rule.max_calls as usize;
let interval_secs = rule.interval_secs;
let window = Duration::try_from_secs_f64(interval_secs).unwrap_or(Duration::ZERO);
let name = format!("rate_limit_tool:{tool}");
let desc = format!(
"project .car/policies rate_limit_tool: {tool} ({max_calls}/{interval_secs}s)"
);
let window_calls: Arc<Mutex<VecDeque<Instant>>> = Arc::new(Mutex::new(VecDeque::new()));
let check: PolicyCheck = Box::new(move |action: &Action, _state| {
if action.tool.as_deref() != Some(tool.as_str()) {
return None;
}
let now = Instant::now();
let mut calls = window_calls.lock().unwrap_or_else(|e| e.into_inner());
while calls
.front()
.is_some_and(|t| now.duration_since(*t) >= window)
{
calls.pop_front();
}
if calls.len() >= max_calls {
return Some(format!(
"tool '{tool}' exceeds the project rate limit of {max_calls} \
call(s) per {interval_secs}s"
));
}
calls.push_back(now);
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,
})?;
if !rules.trace_rule.is_empty() {
return Err(PolicyLoadError::Unenforced {
path: path.clone(),
key: "trace_rule".to_string(),
});
}
merged.merge(rules);
}
Ok(merged)
}
#[derive(Debug, Clone)]
pub enum PolicyLoadError {
Io { path: PathBuf, message: String },
Parse {
path: Option<PathBuf>,
message: String,
},
Unenforced { path: PathBuf, key: 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}"),
},
PolicyLoadError::Unenforced { path, key } => write!(
f,
"policy rule kind '{key}' in {} is not enforced by this build — \
loading it would report a rule that never fires. Remove it, or \
express the prohibition with a rule kind that is enforced.",
path.display()
),
}
}
}
impl std::error::Error for PolicyLoadError {}
#[cfg(test)]
mod tests {
use super::*;
use car_ir::{Action, ActionType};
use car_state::StateStore;
use std::collections::HashMap;
fn tool_action(tool: &str, params: HashMap<String, Value>) -> Action {
{
let mut a = Action::new(ActionType::ToolCall);
a.id = "a1".to_string();
a.tool = Some(tool.to_string());
a.parameters = params;
a.max_retries = 0;
a
}
}
#[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 allow_tool_param_permits_only_listed_values() {
let mut engine = PolicyEngine::new();
PolicyRules {
allow_tool_param: vec![AllowToolParam {
tool: "deploy".to_string(),
param: "target".to_string(),
allow: vec!["staging".to_string(), "preview".to_string()],
}],
..Default::default()
}
.apply(&mut engine);
let state = StateStore::new();
let p1 = [("target".to_string(), Value::from("staging"))].into();
assert!(engine.check(&tool_action("deploy", p1), &state).is_empty());
let p2 = [("target".to_string(), Value::from("production"))].into();
let v = engine.check(&tool_action("deploy", p2), &state);
assert_eq!(v.len(), 1);
assert!(v[0].reason.contains("not allowlisted"));
let p3 = [("target".to_string(), Value::from("Staging"))].into();
assert_eq!(engine.check(&tool_action("deploy", p3), &state).len(), 1);
assert_eq!(
engine
.check(&tool_action("deploy", HashMap::new()), &state)
.len(),
1
);
let p4 = [("target".to_string(), Value::from("production"))].into();
assert!(engine.check(&tool_action("echo", p4), &state).is_empty());
}
#[test]
fn allow_tool_param_with_empty_list_denies_the_tool() {
let mut engine = PolicyEngine::new();
PolicyRules {
allow_tool_param: vec![AllowToolParam {
tool: "deploy".to_string(),
param: "target".to_string(),
allow: vec![],
}],
..Default::default()
}
.apply(&mut engine);
let state = StateStore::new();
let p1 = [("target".to_string(), Value::from("staging"))].into();
assert_eq!(engine.check(&tool_action("deploy", p1), &state).len(), 1);
assert_eq!(
engine
.check(&tool_action("deploy", HashMap::new()), &state)
.len(),
1
);
}
#[test]
fn allow_tool_param_never_echoes_the_value() {
let mut engine = PolicyEngine::new();
PolicyRules {
allow_tool_param: vec![AllowToolParam {
tool: "deploy".to_string(),
param: "target".to_string(),
allow: vec!["staging".to_string()],
}],
..Default::default()
}
.apply(&mut engine);
let state = StateStore::new();
let p = [("target".to_string(), Value::from("+15555550123"))].into();
let v = engine.check(&tool_action("deploy", p), &state);
assert_eq!(v.len(), 1);
assert!(
!v[0].reason.contains("+15555550123"),
"the rejected value identifies a recipient and must not reach the event log"
);
}
#[test]
fn deny_tool_param_matching_fires_on_a_regex_hit() {
let mut engine = PolicyEngine::new();
PolicyRules {
deny_tool_param_matching: vec![DenyToolParamMatching {
tool: "http_request".to_string(),
param: "body".to_string(),
matches: "sk-[A-Za-z0-9]{6,}".to_string(),
}],
..Default::default()
}
.apply(&mut engine);
let state = StateStore::new();
let p1 = [(
"body".to_string(),
Value::from("token sk-ABCdef123456 here"),
)]
.into();
let v = engine.check(&tool_action("http_request", p1), &state);
assert_eq!(v.len(), 1);
assert!(v[0].reason.contains("denied pattern"));
let p2 = [("body".to_string(), Value::from("nothing secret"))].into();
assert!(engine
.check(&tool_action("http_request", p2), &state)
.is_empty());
assert!(engine
.check(&tool_action("http_request", HashMap::new()), &state)
.is_empty());
let p3 = [("body".to_string(), Value::from("sk-ABCdef123456"))].into();
assert!(engine.check(&tool_action("other", p3), &state).is_empty());
}
#[test]
fn deny_tool_param_matching_leaks_neither_match_nor_pattern() {
let mut engine = PolicyEngine::new();
PolicyRules {
deny_tool_param_matching: vec![DenyToolParamMatching {
tool: "http_request".to_string(),
param: "body".to_string(),
matches: "sk-[A-Za-z0-9]{6,}".to_string(),
}],
..Default::default()
}
.apply(&mut engine);
let state = StateStore::new();
let p = [("body".to_string(), Value::from("sk-ABCdef123456"))].into();
let v = engine.check(&tool_action("http_request", p), &state);
assert_eq!(v.len(), 1);
assert!(
!v[0].reason.contains("sk-ABCdef123456"),
"the matched text is the very secret the rule exists to catch"
);
assert!(
!v[0].reason.contains("sk-[A-Za-z0-9]"),
"the pattern narrows what the secret looks like"
);
}
#[test]
fn deny_tool_param_matching_with_an_invalid_regex_fails_closed() {
let mut engine = PolicyEngine::new();
PolicyRules {
deny_tool_param_matching: vec![DenyToolParamMatching {
tool: "http_request".to_string(),
param: "body".to_string(),
matches: "(unclosed".to_string(),
}],
..Default::default()
}
.apply(&mut engine);
let state = StateStore::new();
let p1 = [("body".to_string(), Value::from("harmless"))].into();
let v = engine.check(&tool_action("http_request", p1), &state);
assert_eq!(v.len(), 1);
assert!(v[0].reason.contains("failed to compile"));
assert_eq!(
engine
.check(&tool_action("http_request", HashMap::new()), &state)
.len(),
1
);
assert!(engine
.check(&tool_action("echo", HashMap::new()), &state)
.is_empty());
}
#[test]
fn rate_limit_tool_admits_the_cap_then_denies() {
let mut engine = PolicyEngine::new();
PolicyRules {
rate_limit_tool: vec![RateLimitTool {
tool: "http_request".to_string(),
max_calls: 3,
interval_secs: 3600.0,
}],
..Default::default()
}
.apply(&mut engine);
let state = StateStore::new();
for i in 0..3 {
assert!(
engine
.check(&tool_action("http_request", HashMap::new()), &state)
.is_empty(),
"call {i} is within the cap"
);
}
let v = engine.check(&tool_action("http_request", HashMap::new()), &state);
assert_eq!(v.len(), 1);
assert!(v[0].reason.contains("rate limit"));
assert_eq!(
engine
.check(&tool_action("http_request", HashMap::new()), &state)
.len(),
1
);
assert!(engine
.check(&tool_action("echo", HashMap::new()), &state)
.is_empty());
}
#[test]
fn rate_limit_tool_with_zero_max_denies_immediately() {
let mut engine = PolicyEngine::new();
PolicyRules {
rate_limit_tool: vec![RateLimitTool {
tool: "http_request".to_string(),
max_calls: 0,
interval_secs: 3600.0,
}],
..Default::default()
}
.apply(&mut engine);
let state = StateStore::new();
assert_eq!(
engine
.check(&tool_action("http_request", HashMap::new()), &state)
.len(),
1
);
}
#[test]
fn rate_limit_tool_with_a_nonsense_interval_does_not_panic() {
let mut engine = PolicyEngine::new();
PolicyRules {
rate_limit_tool: vec![RateLimitTool {
tool: "http_request".to_string(),
max_calls: 1,
interval_secs: -5.0,
}],
..Default::default()
}
.apply(&mut engine);
let state = StateStore::new();
for _ in 0..3 {
assert!(engine
.check(&tool_action("http_request", HashMap::new()), &state)
.is_empty());
}
}
#[test]
fn parses_all_six_rule_kinds_from_one_document() {
let src = r#"
deny_tool = ["deploy"]
deny_keyword = ["DROP TABLE"]
[[deny_tool_param]]
tool = "http_request"
param = "url"
contains = "169.254.169.254"
[[allow_tool_param]]
tool = "deploy"
param = "target"
allow = ["staging", "preview"]
[[deny_tool_param_matching]]
tool = "http_request"
param = "body"
matches = "sk-[A-Za-z0-9]{20,}"
[[rate_limit_tool]]
tool = "http_request"
max_calls = 10
interval_secs = 60.0
"#;
let rules = PolicyRules::from_toml(src).unwrap();
assert_eq!(rules.deny_tool, vec!["deploy"]);
assert_eq!(rules.deny_keyword, vec!["DROP TABLE"]);
assert_eq!(rules.deny_tool_param.len(), 1);
assert_eq!(rules.allow_tool_param.len(), 1);
assert_eq!(
rules.allow_tool_param[0].allow,
vec!["staging".to_string(), "preview".to_string()]
);
assert_eq!(rules.deny_tool_param_matching.len(), 1);
assert_eq!(rules.deny_tool_param_matching[0].param, "body");
assert_eq!(rules.rate_limit_tool.len(), 1);
assert_eq!(rules.rate_limit_tool[0].max_calls, 10);
assert_eq!(rules.rate_limit_tool[0].interval_secs, 60.0);
}
#[test]
fn a_rule_set_with_only_a_new_kind_is_not_empty_and_merges() {
let mut a = PolicyRules::from_toml(
r#"
[[allow_tool_param]]
tool = "deploy"
param = "target"
allow = ["staging"]
"#,
)
.unwrap();
assert!(!a.is_empty(), "is_empty must account for allow_tool_param");
let b = PolicyRules::from_toml(
r#"
[[deny_tool_param_matching]]
tool = "http_request"
param = "body"
matches = "secret"
[[rate_limit_tool]]
tool = "http_request"
max_calls = 1
interval_secs = 1.0
"#,
)
.unwrap();
assert!(!b.is_empty(), "is_empty must account for the other two");
a.merge(b);
assert_eq!(a.allow_tool_param.len(), 1);
assert_eq!(a.deny_tool_param_matching.len(), 1);
assert_eq!(a.rate_limit_tool.len(), 1);
}
#[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 an_unenforced_rule_kind_is_refused_rather_than_silently_loaded() {
let dir = std::env::temp_dir().join(format!("car_pol_unenf_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("trace.toml"),
"deny_tool = [\"rm\"]\n\n[[trace_rule]]\nkind = \"never\"\ntool = \"deploy\"\n",
)
.unwrap();
let err = load_policy_dir(&dir).unwrap_err();
match &err {
PolicyLoadError::Unenforced { path, key } => {
assert_eq!(key, "trace_rule");
assert!(path.ends_with("trace.toml"), "names the offending file");
}
other => panic!("expected Unenforced, got {other:?}"),
}
let msg = err.to_string();
assert!(msg.contains("not enforced"), "{msg}");
assert!(msg.contains("trace.toml"), "{msg}");
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();
}
}
#[cfg(test)]
mod trace_rule_tests {
use super::*;
use car_verify::trace_policy::{TraceGate, TraceRule};
#[test]
fn trace_rules_parse_from_a_policy_file() {
let src = r#"
deny_tool = ["rm_rf"]
[[trace_rule]]
kind = "precedes"
earlier = "test"
later = "deploy"
[[trace_rule]]
kind = "until"
start = "fetch_url"
forbidden = "write_file"
release = "approval"
name = "no_write_after_fetch_without_approval"
"#;
let rules = PolicyRules::from_toml(src).expect("parses");
assert_eq!(rules.deny_tool, vec!["rm_rf"]);
assert_eq!(rules.trace_rule.len(), 2);
assert!(matches!(rules.trace_rule[0], TraceRule::Precedes { .. }));
assert_eq!(
rules.trace_rule[1].label(),
"no_write_after_fetch_without_approval"
);
}
#[test]
fn a_file_without_trace_rules_still_parses() {
let rules = PolicyRules::from_toml(r#"deny_tool = ["x"]"#).unwrap();
assert!(rules.trace_rule.is_empty());
assert!(!rules.is_empty(), "it still has a deny_tool");
}
#[test]
fn a_rule_set_with_only_trace_rules_is_not_empty() {
let src = r#"
[[trace_rule]]
kind = "never"
tool = "rm_rf"
"#;
let rules = PolicyRules::from_toml(src).unwrap();
assert!(
!rules.is_empty(),
"is_empty must account for trace rules, or a project governed only \
by them would be treated as having no policy at all"
);
}
#[test]
fn merging_two_files_unions_their_trace_rules() {
let mut a = PolicyRules::from_toml(
r#"
[[trace_rule]]
kind = "never"
tool = "a"
"#,
)
.unwrap();
let b = PolicyRules::from_toml(
r#"
[[trace_rule]]
kind = "never"
tool = "b"
"#,
)
.unwrap();
a.merge(b);
assert_eq!(a.trace_rule.len(), 2);
}
#[test]
fn an_authored_rule_gates_a_live_call() {
let rules = PolicyRules::from_toml(
r#"
[[trace_rule]]
kind = "precedes"
earlier = "test"
later = "deploy"
"#,
)
.unwrap();
let mut gate = TraceGate::new(rules.trace_rule);
assert_eq!(gate.check("deploy").len(), 1, "no test has run");
gate.record("test", true);
assert!(gate.check("deploy").is_empty());
}
}