1use crate::config::v2::{Policy, PolicyAction, PolicyCaller};
21use crate::sec::scope::TrifectaTag;
22use serde_json::Value;
23
24pub struct Call<'a> {
26 pub tool: &'a str,
27 pub tags: &'a [TrifectaTag],
30 pub caller: PolicyCaller,
31 pub principal: Option<&'a str>,
32 pub args: &'a Value,
33}
34
35pub struct Verdict {
38 pub action: PolicyAction,
39 pub rule: usize,
40 pub question: Option<String>,
41 pub on_timeout: PolicyAction,
42 pub timeout_ms: Option<u64>,
43}
44
45fn tag_name(t: TrifectaTag) -> &'static str {
47 match t {
48 TrifectaTag::UntrustedInput => "untrusted_input",
49 TrifectaTag::Sensitive => "sensitive",
50 TrifectaTag::Egress => "egress",
51 }
52}
53
54fn matches(p: &Policy, call: &Call<'_>, cel_ok: &mut bool) -> bool {
59 if let Some(pat) = &p.matcher.tool
60 && !crate::registry::pattern_matches(pat, call.tool)
61 {
62 return false;
63 }
64 if !p.matcher.tags.is_empty() {
65 let have: Vec<&str> = call.tags.iter().map(|t| tag_name(*t)).collect();
66 if !p.matcher.tags.iter().all(|w| have.contains(&w.as_str())) {
67 return false;
68 }
69 }
70 if !p.matcher.caller.is_empty() && !p.matcher.caller.contains(&call.caller) {
71 return false;
72 }
73 if let Some(pat) = &p.matcher.principal {
74 match call.principal {
75 None => return false,
76 Some(id) if !crate::registry::pattern_matches(pat, id) => return false,
77 Some(_) => {}
78 }
79 }
80 if let Some(expr) = &p.matcher.args {
81 let tool = Value::String(call.tool.to_string());
82 let caller = Value::String(caller_name(call.caller).to_string());
83 let vars: Vec<(&str, &Value)> =
84 vec![("args", call.args), ("tool", &tool), ("caller", &caller)];
85 match crate::cel::eval_bool(expr.trim().trim_start_matches("CEL:").trim(), &vars) {
86 Ok(true) => {}
87 Ok(false) => return false,
88 Err(_) => {
89 *cel_ok = false;
94 return false;
95 }
96 }
97 }
98 true
99}
100
101pub fn caller_name(c: PolicyCaller) -> &'static str {
102 match c {
103 PolicyCaller::Root => "root",
104 PolicyCaller::Workflow => "workflow",
105 PolicyCaller::Subagent => "subagent",
106 }
107}
108
109pub fn evaluate(policies: &[Policy], call: &Call<'_>) -> Result<Option<Verdict>, usize> {
113 for (i, p) in policies.iter().enumerate() {
114 let mut cel_ok = true;
115 let hit = matches(p, call, &mut cel_ok);
116 if !cel_ok {
117 return Err(i);
118 }
119 if !hit {
120 continue;
121 }
122 if p.action == PolicyAction::Allow {
123 return Ok(Some(Verdict {
126 action: PolicyAction::Allow,
127 rule: i,
128 question: None,
129 on_timeout: PolicyAction::Deny,
130 timeout_ms: None,
131 }));
132 }
133 return Ok(Some(Verdict {
134 action: p.action,
135 rule: i,
136 question: p.question.clone(),
137 on_timeout: p.on_timeout.unwrap_or(PolicyAction::Deny),
139 timeout_ms: p.timeout.as_ref().map(|d| d.0.as_millis() as u64),
140 }));
141 }
142 Ok(None)
143}
144
145pub fn could_apply(
156 policies: &[Policy],
157 tool: &str,
158 tags: &[TrifectaTag],
159 caller: PolicyCaller,
160) -> bool {
161 policies.iter().any(|p| {
162 if let Some(pat) = &p.matcher.tool
163 && !crate::registry::pattern_matches(pat, tool)
164 {
165 return false;
166 }
167 if !p.matcher.tags.is_empty() {
168 let have: Vec<&str> = tags.iter().map(|t| tag_name(*t)).collect();
169 if !p.matcher.tags.iter().all(|w| have.contains(&w.as_str())) {
170 return false;
171 }
172 }
173 if !p.matcher.caller.is_empty() && !p.matcher.caller.contains(&caller) {
174 return false;
175 }
176 true
179 })
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::config::v2::PolicyMatch;
186
187 fn pol(m: PolicyMatch, a: PolicyAction) -> Policy {
188 Policy {
189 matcher: m,
190 action: a,
191 ..Default::default()
192 }
193 }
194
195 fn call<'a>(
196 tool: &'a str,
197 tags: &'a [TrifectaTag],
198 caller: PolicyCaller,
199 args: &'a Value,
200 ) -> Call<'a> {
201 Call {
202 tool,
203 tags,
204 caller,
205 principal: None,
206 args,
207 }
208 }
209
210 #[test]
211 fn no_rules_is_allow_and_costs_nothing() {
212 let args = Value::Null;
213 let c = call("anything", &[], PolicyCaller::Root, &args);
214 assert!(evaluate(&[], &c).unwrap().is_none());
215 }
216
217 #[test]
218 fn first_match_wins_so_an_exception_can_precede_a_broad_deny() {
219 let args = Value::Null;
220 let rules = vec![
221 pol(
222 PolicyMatch {
223 tool: Some("fs.read".into()),
224 ..Default::default()
225 },
226 PolicyAction::Allow,
227 ),
228 pol(
229 PolicyMatch {
230 tool: Some("fs.*".into()),
231 ..Default::default()
232 },
233 PolicyAction::Deny,
234 ),
235 ];
236 let v = evaluate(&rules, &call("fs.read", &[], PolicyCaller::Root, &args))
237 .unwrap()
238 .expect("matched");
239 assert_eq!(v.action, PolicyAction::Allow);
240 let v = evaluate(&rules, &call("fs.delete", &[], PolicyCaller::Root, &args))
241 .unwrap()
242 .expect("matched");
243 assert_eq!(v.action, PolicyAction::Deny);
244 }
245
246 #[test]
249 fn tag_conditions_require_all_of_them() {
250 let args = Value::Null;
251 let rules = vec![pol(
252 PolicyMatch {
253 tags: vec!["sensitive".into(), "egress".into()],
254 ..Default::default()
255 },
256 PolicyAction::Deny,
257 )];
258 let both = [TrifectaTag::Sensitive, TrifectaTag::Egress];
259 let one = [TrifectaTag::Egress];
260 assert!(
261 evaluate(&rules, &call("t", &both, PolicyCaller::Root, &args))
262 .unwrap()
263 .is_some()
264 );
265 assert!(
266 evaluate(&rules, &call("t", &one, PolicyCaller::Root, &args))
267 .unwrap()
268 .is_none()
269 );
270 }
271
272 #[test]
273 fn caller_narrows_rather_than_widens() {
274 let args = Value::Null;
275 let rules = vec![pol(
276 PolicyMatch {
277 tool: Some("*".into()),
278 caller: vec![PolicyCaller::Subagent],
279 ..Default::default()
280 },
281 PolicyAction::Deny,
282 )];
283 assert!(
284 evaluate(&rules, &call("t", &[], PolicyCaller::Subagent, &args))
285 .unwrap()
286 .is_some()
287 );
288 assert!(
289 evaluate(&rules, &call("t", &[], PolicyCaller::Root, &args))
290 .unwrap()
291 .is_none()
292 );
293 }
294
295 #[test]
299 fn could_apply_is_conservative_about_call_time_facts() {
300 let rules = vec![pol(
301 PolicyMatch {
302 tool: Some("fs.*".into()),
303 args: Some("CEL: args.path != '/tmp'".into()),
304 ..Default::default()
305 },
306 PolicyAction::Deny,
307 )];
308 assert!(could_apply(
309 &rules,
310 "fs.delete",
311 &[],
312 PolicyCaller::Subagent
313 ));
314 assert!(!could_apply(
315 &rules,
316 "http.get",
317 &[],
318 PolicyCaller::Subagent
319 ));
320 }
321
322 #[test]
326 #[cfg(feature = "cel")]
327 fn an_unevaluatable_argument_guard_fails_closed() {
328 let args = serde_json::json!({"path": "/etc"});
329 let rules = vec![pol(
330 PolicyMatch {
331 tool: Some("*".into()),
332 args: Some("CEL: this is not an expression((".into()),
333 ..Default::default()
334 },
335 PolicyAction::Deny,
336 )];
337 assert!(evaluate(&rules, &call("t", &[], PolicyCaller::Root, &args)).is_err());
338 }
339}