use crate::config::v2::{Policy, PolicyAction, PolicyCaller};
use crate::sec::scope::TrifectaTag;
use serde_json::Value;
pub struct Call<'a> {
pub tool: &'a str,
pub tags: &'a [TrifectaTag],
pub caller: PolicyCaller,
pub principal: Option<&'a str>,
pub args: &'a Value,
}
pub struct Verdict {
pub action: PolicyAction,
pub rule: usize,
pub question: Option<String>,
pub on_timeout: PolicyAction,
pub timeout_ms: Option<u64>,
}
fn tag_name(t: TrifectaTag) -> &'static str {
match t {
TrifectaTag::UntrustedInput => "untrusted_input",
TrifectaTag::Sensitive => "sensitive",
TrifectaTag::Egress => "egress",
}
}
fn matches(p: &Policy, call: &Call<'_>, cel_ok: &mut bool) -> bool {
if let Some(pat) = &p.matcher.tool
&& !crate::registry::pattern_matches(pat, call.tool)
{
return false;
}
if !p.matcher.tags.is_empty() {
let have: Vec<&str> = call.tags.iter().map(|t| tag_name(*t)).collect();
if !p.matcher.tags.iter().all(|w| have.contains(&w.as_str())) {
return false;
}
}
if !p.matcher.caller.is_empty() && !p.matcher.caller.contains(&call.caller) {
return false;
}
if let Some(pat) = &p.matcher.principal {
match call.principal {
None => return false,
Some(id) if !crate::registry::pattern_matches(pat, id) => return false,
Some(_) => {}
}
}
if let Some(expr) = &p.matcher.args {
let tool = Value::String(call.tool.to_string());
let caller = Value::String(caller_name(call.caller).to_string());
let vars: Vec<(&str, &Value)> =
vec![("args", call.args), ("tool", &tool), ("caller", &caller)];
match crate::cel::eval_bool(expr.trim().trim_start_matches("CEL:").trim(), &vars) {
Ok(true) => {}
Ok(false) => return false,
Err(_) => {
*cel_ok = false;
return false;
}
}
}
true
}
pub fn caller_name(c: PolicyCaller) -> &'static str {
match c {
PolicyCaller::Root => "root",
PolicyCaller::Workflow => "workflow",
PolicyCaller::Subagent => "subagent",
}
}
pub fn evaluate(policies: &[Policy], call: &Call<'_>) -> Result<Option<Verdict>, usize> {
for (i, p) in policies.iter().enumerate() {
let mut cel_ok = true;
let hit = matches(p, call, &mut cel_ok);
if !cel_ok {
return Err(i);
}
if !hit {
continue;
}
if p.action == PolicyAction::Allow {
return Ok(Some(Verdict {
action: PolicyAction::Allow,
rule: i,
question: None,
on_timeout: PolicyAction::Deny,
timeout_ms: None,
}));
}
return Ok(Some(Verdict {
action: p.action,
rule: i,
question: p.question.clone(),
on_timeout: p.on_timeout.unwrap_or(PolicyAction::Deny),
timeout_ms: p.timeout.as_ref().map(|d| d.0.as_millis() as u64),
}));
}
Ok(None)
}
pub fn could_apply(
policies: &[Policy],
tool: &str,
tags: &[TrifectaTag],
caller: PolicyCaller,
) -> bool {
policies.iter().any(|p| {
if let Some(pat) = &p.matcher.tool
&& !crate::registry::pattern_matches(pat, tool)
{
return false;
}
if !p.matcher.tags.is_empty() {
let have: Vec<&str> = tags.iter().map(|t| tag_name(*t)).collect();
if !p.matcher.tags.iter().all(|w| have.contains(&w.as_str())) {
return false;
}
}
if !p.matcher.caller.is_empty() && !p.matcher.caller.contains(&caller) {
return false;
}
true
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::v2::PolicyMatch;
fn pol(m: PolicyMatch, a: PolicyAction) -> Policy {
Policy {
matcher: m,
action: a,
..Default::default()
}
}
fn call<'a>(
tool: &'a str,
tags: &'a [TrifectaTag],
caller: PolicyCaller,
args: &'a Value,
) -> Call<'a> {
Call {
tool,
tags,
caller,
principal: None,
args,
}
}
#[test]
fn no_rules_is_allow_and_costs_nothing() {
let args = Value::Null;
let c = call("anything", &[], PolicyCaller::Root, &args);
assert!(evaluate(&[], &c).unwrap().is_none());
}
#[test]
fn first_match_wins_so_an_exception_can_precede_a_broad_deny() {
let args = Value::Null;
let rules = vec![
pol(
PolicyMatch {
tool: Some("fs.read".into()),
..Default::default()
},
PolicyAction::Allow,
),
pol(
PolicyMatch {
tool: Some("fs.*".into()),
..Default::default()
},
PolicyAction::Deny,
),
];
let v = evaluate(&rules, &call("fs.read", &[], PolicyCaller::Root, &args))
.unwrap()
.expect("matched");
assert_eq!(v.action, PolicyAction::Allow);
let v = evaluate(&rules, &call("fs.delete", &[], PolicyCaller::Root, &args))
.unwrap()
.expect("matched");
assert_eq!(v.action, PolicyAction::Deny);
}
#[test]
fn tag_conditions_require_all_of_them() {
let args = Value::Null;
let rules = vec![pol(
PolicyMatch {
tags: vec!["sensitive".into(), "egress".into()],
..Default::default()
},
PolicyAction::Deny,
)];
let both = [TrifectaTag::Sensitive, TrifectaTag::Egress];
let one = [TrifectaTag::Egress];
assert!(
evaluate(&rules, &call("t", &both, PolicyCaller::Root, &args))
.unwrap()
.is_some()
);
assert!(
evaluate(&rules, &call("t", &one, PolicyCaller::Root, &args))
.unwrap()
.is_none()
);
}
#[test]
fn caller_narrows_rather_than_widens() {
let args = Value::Null;
let rules = vec![pol(
PolicyMatch {
tool: Some("*".into()),
caller: vec![PolicyCaller::Subagent],
..Default::default()
},
PolicyAction::Deny,
)];
assert!(
evaluate(&rules, &call("t", &[], PolicyCaller::Subagent, &args))
.unwrap()
.is_some()
);
assert!(
evaluate(&rules, &call("t", &[], PolicyCaller::Root, &args))
.unwrap()
.is_none()
);
}
#[test]
fn could_apply_is_conservative_about_call_time_facts() {
let rules = vec![pol(
PolicyMatch {
tool: Some("fs.*".into()),
args: Some("CEL: args.path != '/tmp'".into()),
..Default::default()
},
PolicyAction::Deny,
)];
assert!(could_apply(
&rules,
"fs.delete",
&[],
PolicyCaller::Subagent
));
assert!(!could_apply(
&rules,
"http.get",
&[],
PolicyCaller::Subagent
));
}
#[test]
#[cfg(feature = "cel")]
fn an_unevaluatable_argument_guard_fails_closed() {
let args = serde_json::json!({"path": "/etc"});
let rules = vec![pol(
PolicyMatch {
tool: Some("*".into()),
args: Some("CEL: this is not an expression((".into()),
..Default::default()
},
PolicyAction::Deny,
)];
assert!(evaluate(&rules, &call("t", &[], PolicyCaller::Root, &args)).is_err());
}
}