pub fn degrades(success_count: u64, fail_count: u64, threshold: u64) -> bool {
fail_count > success_count + threshold
}
pub const DEGRADE_THRESHOLD: u64 = 2;
pub mod agent_permissions;
pub mod flow_gate;
pub mod inspectors;
pub mod intent_gate;
pub mod permission;
pub mod rules;
pub mod skill_trust;
pub mod tool_gate;
pub use agent_permissions::{AgentPermissionPolicy, ApprovalMode, ApprovalPreset, TierPosture};
pub use flow_gate::{enforce_flow, flow_fingerprint, FlowEnforcement, PendingFlowApproval};
pub use intent_gate::{
enforce_intent, intent_fingerprint, IntentEnforcement, PendingIntentApproval,
};
pub use inspectors::{
load_adversary_rules_from, AdversaryInspector, EgressInspector, InspectionResult, Inspector,
InspectorChain, RepetitionInspector,
};
pub use permission::{
action_fingerprint, action_text, classify_reversibility, classify_reversibility_with_haystack,
ActionAxes, ApprovalDecision, ApprovalLedger, ApprovalRecord, GateDecision, PermissionGate,
PermissionTier, RiskClassifier,
};
pub use rules::{load_policy_dir, DenyToolParam, PolicyLoadError, PolicyRules};
use car_ir::Action;
use car_state::StateStore;
use std::collections::BTreeSet;
use std::panic::{self, AssertUnwindSafe};
#[derive(Debug, Clone)]
pub struct PolicyViolation {
pub policy_name: String,
pub action_id: String,
pub reason: String,
}
pub type PolicyCheck = Box<dyn Fn(&Action, &StateStore) -> Option<String> + Send + Sync>;
pub struct PolicyEngine {
policies: Vec<(String, String, PolicyCheck)>, blanket_tool_denies: Vec<(String, String)>,
}
impl PolicyEngine {
pub fn new() -> Self {
Self {
policies: Vec::new(),
blanket_tool_denies: Vec::new(),
}
}
pub fn register(&mut self, name: &str, check: PolicyCheck, description: &str) {
self.policies
.push((name.to_string(), description.to_string(), check));
}
pub fn register_tool_deny(
&mut self,
name: &str,
tool: &str,
check: PolicyCheck,
description: &str,
) {
self.register(name, check, description);
self.blanket_tool_denies
.push((name.to_string(), tool.to_string()));
}
pub fn check(&self, action: &Action, state: &StateStore) -> Vec<PolicyViolation> {
let mut violations = Vec::new();
for (name, _, check_fn) in &self.policies {
let result = panic::catch_unwind(AssertUnwindSafe(|| check_fn(action, state)));
match result {
Ok(Some(reason)) => {
violations.push(PolicyViolation {
policy_name: name.clone(),
action_id: action.id.clone(),
reason,
});
}
Ok(None) => {} Err(_) => {
violations.push(PolicyViolation {
policy_name: name.clone(),
action_id: action.id.clone(),
reason: format!("policy '{}' panicked during check", name),
});
}
}
}
violations
}
pub fn unregister(&mut self, name: &str) -> usize {
let before = self.policies.len();
self.policies.retain(|(n, _, _)| n != name);
self.blanket_tool_denies.retain(|(n, _)| n != name);
before - self.policies.len()
}
pub fn clear(&mut self) -> usize {
let n = self.policies.len();
self.policies.clear();
self.blanket_tool_denies.clear();
n
}
pub fn blanket_denied_tools(&self) -> BTreeSet<String> {
self.blanket_tool_denies
.iter()
.map(|(_, tool)| tool.clone())
.collect()
}
pub fn policy_names(&self) -> Vec<String> {
self.policies.iter().map(|(n, _, _)| n.clone()).collect()
}
pub fn policy_details(&self) -> Vec<(String, String)> {
self.policies
.iter()
.map(|(n, d, _)| (n.clone(), d.clone()))
.collect()
}
pub fn is_empty(&self) -> bool {
self.policies.is_empty()
}
}
impl Default for PolicyEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use car_ir::ActionType;
use serde_json::Value;
fn make_action(tool: &str) -> Action {
{
let mut a = Action::new(ActionType::ToolCall);
a.id = "test".to_string();
a.tool = Some(tool.to_string());
a
}
}
#[test]
fn no_policies_passes() {
let engine = PolicyEngine::new();
let state = StateStore::new();
let violations = engine.check(&make_action("echo"), &state);
assert!(violations.is_empty());
}
#[test]
fn policy_blocks_action() {
let mut engine = PolicyEngine::new();
engine.register(
"no_echo",
Box::new(|action, _state| {
if action.tool.as_deref() == Some("echo") {
Some("echo is forbidden".to_string())
} else {
None
}
}),
"Block echo tool",
);
let state = StateStore::new();
let violations = engine.check(&make_action("echo"), &state);
assert_eq!(violations.len(), 1);
assert!(violations[0].reason.contains("forbidden"));
}
#[test]
fn policy_allows_other_tools() {
let mut engine = PolicyEngine::new();
engine.register(
"no_echo",
Box::new(|action, _state| {
if action.tool.as_deref() == Some("echo") {
Some("forbidden".to_string())
} else {
None
}
}),
"",
);
let state = StateStore::new();
let violations = engine.check(&make_action("add"), &state);
assert!(violations.is_empty());
}
#[test]
fn policy_checks_state() {
let mut engine = PolicyEngine::new();
engine.register(
"require_auth",
Box::new(|_action, state| {
if state.get("auth") != Some(Value::Bool(true)) {
Some("auth required".to_string())
} else {
None
}
}),
"",
);
let state = StateStore::new();
let violations = engine.check(&make_action("deploy"), &state);
assert_eq!(violations.len(), 1);
state.set("auth", Value::Bool(true), "setup");
let violations2 = engine.check(&make_action("deploy"), &state);
assert!(violations2.is_empty());
}
#[test]
fn panicking_policy_caught() {
let mut engine = PolicyEngine::new();
engine.register(
"crasher",
Box::new(|_action, _state| {
panic!("policy crashed");
}),
"",
);
let state = StateStore::new();
let violations = engine.check(&make_action("anything"), &state);
assert_eq!(violations.len(), 1);
assert!(violations[0].reason.contains("panicked"));
}
#[test]
fn multiple_policies() {
let mut engine = PolicyEngine::new();
engine.register("p1", Box::new(|_, _| Some("fail 1".to_string())), "");
engine.register("p2", Box::new(|_, _| None), "");
engine.register("p3", Box::new(|_, _| Some("fail 3".to_string())), "");
let state = StateStore::new();
let violations = engine.check(&make_action("x"), &state);
assert_eq!(violations.len(), 2);
}
#[test]
fn policy_names() {
let mut engine = PolicyEngine::new();
engine.register("alpha", Box::new(|_, _| None), "");
engine.register("beta", Box::new(|_, _| None), "");
assert_eq!(engine.policy_names(), vec!["alpha", "beta"]);
}
#[test]
fn unregister_removes_the_policy_and_stops_enforcement() {
let mut engine = PolicyEngine::new();
engine.register("deny", Box::new(|_, _| Some("nope".to_string())), "");
engine.register("keep", Box::new(|_, _| None), "");
let state = StateStore::new();
assert_eq!(engine.check(&make_action("x"), &state).len(), 1);
assert_eq!(engine.unregister("deny"), 1);
assert_eq!(engine.policy_names(), vec!["keep"]);
assert!(
engine.check(&make_action("x"), &state).is_empty(),
"an unregistered policy must stop being enforced"
);
}
#[test]
fn unregister_reports_zero_when_nothing_matched() {
let mut engine = PolicyEngine::new();
engine.register("alpha", Box::new(|_, _| None), "");
assert_eq!(engine.unregister("nosuch"), 0);
assert_eq!(engine.policy_names(), vec!["alpha"]);
}
#[test]
fn unregister_removes_every_policy_sharing_the_name() {
let mut engine = PolicyEngine::new();
engine.register("dup", Box::new(|_, _| Some("a".to_string())), "");
engine.register("dup", Box::new(|_, _| Some("b".to_string())), "");
let state = StateStore::new();
assert_eq!(engine.check(&make_action("x"), &state).len(), 2);
assert_eq!(engine.unregister("dup"), 2);
assert!(engine.is_empty());
assert!(engine.check(&make_action("x"), &state).is_empty());
}
#[test]
fn clear_drops_everything() {
let mut engine = PolicyEngine::new();
engine.register("a", Box::new(|_, _| None), "");
engine.register("b", Box::new(|_, _| None), "");
assert_eq!(engine.clear(), 2);
assert!(engine.is_empty());
}
fn engine_from_toml(src: &str) -> PolicyEngine {
let mut engine = PolicyEngine::new();
PolicyRules::from_toml(src)
.expect("fixture policy must parse")
.apply(&mut engine);
engine
}
#[test]
fn blanket_denied_tools_reads_back_what_apply_registered() {
let engine = engine_from_toml("deny_tool = [\"shell\", \"deploy\"]\n");
let denied = engine.blanket_denied_tools();
assert!(denied.contains("shell"), "got {denied:?}");
assert!(denied.contains("deploy"), "got {denied:?}");
assert!(!denied.contains("read_file"), "got {denied:?}");
assert_eq!(denied.len(), 2, "got {denied:?}");
}
#[test]
fn blanket_denied_tools_excludes_argument_dependent_rules() {
let engine = engine_from_toml(
"deny_keyword = [\"rm -rf /\"]\n\n\
[[deny_tool_param]]\n\
tool = \"shell\"\n\
param = \"command\"\n\
contains = \"sudo\"\n\n\
[[allow_tool_param]]\n\
tool = \"deploy\"\n\
param = \"env\"\n\
allow = [\"staging\"]\n",
);
assert!(
engine.blanket_denied_tools().is_empty(),
"argument-dependent rules must not hide their tool: {:?}",
engine.blanket_denied_tools()
);
let state = StateStore::new();
let mut sudo = make_action("shell");
sudo.parameters.insert(
"command".to_string(),
Value::String("sudo reboot".to_string()),
);
assert!(
!engine.check(&sudo, &state).is_empty(),
"deny_tool_param must still refuse the offending call"
);
let mut staging = make_action("deploy");
staging
.parameters
.insert("env".to_string(), Value::String("staging".to_string()));
assert!(
engine.check(&staging, &state).is_empty(),
"the allowlisted value must be permitted"
);
let mut prod = make_action("deploy");
prod.parameters
.insert("env".to_string(), Value::String("prod".to_string()));
assert!(
!engine.check(&prod, &state).is_empty(),
"a value outside the allowlist must be refused"
);
}
#[test]
fn blanket_denied_tools_follows_unregister() {
let mut engine = engine_from_toml("deny_tool = [\"shell\"]\n");
assert!(engine.blanket_denied_tools().contains("shell"));
let name = engine
.policy_names()
.into_iter()
.find(|n| n.contains("shell"))
.expect("apply must register a named check for the denied tool");
assert_eq!(engine.unregister(&name), 1);
assert!(engine.blanket_denied_tools().is_empty());
let state = StateStore::new();
assert!(engine.check(&make_action("shell"), &state).is_empty());
}
#[test]
fn a_tool_deny_is_found_under_any_policy_name() {
let mut engine = PolicyEngine::new();
engine.register_tool_deny(
"no_shell",
"shell",
Box::new(|action, _| {
(action.tool.as_deref() == Some("shell")).then(|| "tool 'shell' denied".to_string())
}),
"",
);
assert!(engine.blanket_denied_tools().contains("shell"));
let mut spoof = PolicyEngine::new();
spoof.register("deny_tool:shell", Box::new(|_, _| None), "");
assert!(
spoof.blanket_denied_tools().is_empty(),
"a name is not a declaration"
);
}
#[test]
fn a_misspelled_policy_field_is_refused_not_reinterpreted() {
let err = PolicyRules::from_toml(
"[[allow_tool_param]]\n\
tool = \"deploy\"\n\
param = \"env\"\n\
values = [\"staging\"]\n",
)
.expect_err("an unknown field must be an error");
let msg = err.to_string();
assert!(
msg.contains("values"),
"the error must name the field: {msg}"
);
PolicyRules::from_toml(
"[[allow_tool_param]]\n\
tool = \"deploy\"\n\
param = \"env\"\n\
allow = [\"staging\"]\n",
)
.expect("the correct spelling must still parse");
}
#[test]
fn policy_details_carries_descriptions() {
let mut engine = PolicyEngine::new();
engine.register("alpha", Box::new(|_, _| None), "first one");
assert_eq!(
engine.policy_details(),
vec![("alpha".to_string(), "first one".to_string())]
);
}
}