use std::collections::HashMap;
use std::path::{Path, PathBuf};
use car_ir::Action;
use car_state::StateStore;
use crate::{InspectionResult, InspectorChain, PolicyEngine};
use serde_json::{json, Value};
struct Finding {
source: String,
rule: String,
severity: &'static str,
reason: String,
}
impl Finding {
fn to_json(&self) -> Value {
json!({
"source": self.source,
"rule": self.rule,
"severity": self.severity,
"reason": self.reason,
})
}
}
fn policy_dirs(cwd: &Path) -> Vec<PathBuf> {
vec![
car_home::root_or_relative().join("policies"),
cwd.join(".car").join("policies"),
]
}
pub fn check(tool: &str, params: &Value, cwd: &Path) -> Value {
let mut findings: Vec<Finding> = Vec::new();
let mut sources: Vec<Value> = Vec::new();
let mut rules_loaded = 0usize;
let mut load_errors: Vec<Value> = Vec::new();
let mut merged = crate::PolicyRules::default();
for dir in policy_dirs(cwd) {
match crate::load_policy_dir(&dir) {
Ok(rules) => {
let n = rules.len();
rules_loaded += n;
sources.push(json!({
"path": dir.display().to_string(),
"exists": dir.exists(),
"rules": n,
}));
merged.merge(rules);
}
Err(e) => {
load_errors.push(json!({
"path": dir.display().to_string(),
"error": e.to_string(),
}));
}
}
}
if !load_errors.is_empty() {
return json!({
"decision": "deny",
"basis": "policy_load_failed",
"tool": tool,
"findings": [],
"policy_sources": sources,
"load_errors": load_errors,
"advice": "A policy file could not be parsed. Fix it, or the gate cannot \
tell an allowed call from an ungoverned one — it fails closed.",
});
}
let mut engine = PolicyEngine::new();
merged.apply(&mut engine);
let mut action = Action::tool_call(tool);
action.id = "policy-check".to_string();
action.parameters = params
.as_object()
.map(|m| {
m.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<HashMap<_, _>>()
})
.unwrap_or_default();
let state = StateStore::new();
for violation in engine.check(&action, &state) {
findings.push(Finding {
source: "policy_rules".to_string(),
rule: violation.policy_name,
severity: "deny",
reason: violation.reason,
});
}
let chain = InspectorChain::new().with(Box::new(crate::EgressInspector::default()));
for (name, result) in chain.inspect(tool, params) {
match result {
InspectionResult::Allow => {}
InspectionResult::Warn(reason) => findings.push(Finding {
source: format!("inspector:{name}"),
rule: name.clone(),
severity: "warn",
reason,
}),
InspectionResult::Deny(reason) => findings.push(Finding {
source: format!("inspector:{name}"),
rule: name.clone(),
severity: "deny",
reason,
}),
}
}
let denied = findings.iter().any(|f| f.severity == "deny");
let basis = if denied {
"denied_by_rule"
} else if rules_loaded == 0 {
"no_rules_configured"
} else {
"passed_rules"
};
json!({
"decision": if denied { "deny" } else { "allow" },
"basis": basis,
"tool": tool,
"findings": findings.iter().map(Finding::to_json).collect::<Vec<_>>(),
"rules_loaded": rules_loaded,
"policy_sources": sources,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn with_policy(dir: &Path, toml: &str) {
let p = dir.join(".car").join("policies");
std::fs::create_dir_all(&p).unwrap();
std::fs::write(p.join("rules.toml"), toml).unwrap();
}
#[test]
fn an_allow_with_no_rules_says_so() {
let dir = tempfile::tempdir().unwrap();
let out = check("Bash", &json!({ "command": "ls" }), dir.path());
assert_eq!(out["decision"], "allow");
assert_eq!(out["basis"], "no_rules_configured");
assert_eq!(out["rules_loaded"], 0);
}
#[test]
fn a_denied_tool_is_denied_with_the_rule_that_did_it() {
let dir = tempfile::tempdir().unwrap();
with_policy(dir.path(), "deny_tool = [\"Bash\"]\n");
let out = check("Bash", &json!({ "command": "ls" }), dir.path());
assert_eq!(out["decision"], "deny");
assert_eq!(out["basis"], "denied_by_rule");
assert_eq!(out["findings"][0]["source"], "policy_rules");
assert_eq!(out["findings"][0]["severity"], "deny");
}
#[test]
fn a_passing_call_under_real_rules_is_distinguishable_from_an_ungoverned_one() {
let dir = tempfile::tempdir().unwrap();
with_policy(dir.path(), "deny_tool = [\"Write\"]\n");
let out = check("Bash", &json!({ "command": "ls" }), dir.path());
assert_eq!(out["decision"], "allow");
assert_eq!(out["basis"], "passed_rules");
assert!(out["rules_loaded"].as_u64().unwrap() >= 1);
}
#[test]
fn a_denied_keyword_is_caught_in_any_string_parameter() {
let dir = tempfile::tempdir().unwrap();
with_policy(dir.path(), "deny_keyword = [\"rm -rf /\"]\n");
let out = check(
"Bash",
&json!({ "command": "sudo rm -rf / --no-preserve-root" }),
dir.path(),
);
assert_eq!(out["decision"], "deny", "{out}");
}
#[test]
fn an_unparseable_policy_file_fails_closed() {
let dir = tempfile::tempdir().unwrap();
with_policy(dir.path(), "deny_tool = [ this is not toml\n");
let out = check("Bash", &json!({ "command": "ls" }), dir.path());
assert_eq!(out["decision"], "deny");
assert_eq!(out["basis"], "policy_load_failed");
assert!(!out["load_errors"].as_array().unwrap().is_empty());
}
#[test]
fn the_response_names_every_directory_it_consulted() {
let dir = tempfile::tempdir().unwrap();
let out = check("Bash", &json!({}), dir.path());
let sources = out["policy_sources"].as_array().unwrap();
assert_eq!(sources.len(), 2, "{out}");
let expected = dir.path().join(".car").join("policies");
assert!(
sources
.iter()
.any(|s| Path::new(s["path"].as_str().unwrap()) == expected),
"{out}"
);
}
#[test]
fn params_that_are_not_an_object_do_not_panic() {
let dir = tempfile::tempdir().unwrap();
let out = check("Bash", &json!("just a string"), dir.path());
assert_eq!(out["decision"], "allow");
}
}