1use crate::policy::*;
2use crate::trace::TraceRecord;
3use uuid::Uuid;
4
5pub struct PolicyEngine {
6 policies: Vec<PolicyRule>,
7}
8
9impl PolicyEngine {
10 pub fn new(policies: Vec<PolicyRule>) -> Self {
11 Self { policies }
12 }
13
14 pub fn with_defaults() -> Self {
15 Self::new(default_policies())
16 }
17
18 pub fn evaluate(&self, trace: &TraceRecord) -> Vec<PolicyEvaluation> {
19 self.policies
20 .iter()
21 .filter(|p| p.enabled)
22 .map(|p| evaluate_policy(p, trace))
23 .collect()
24 }
25}
26
27fn evaluate_policy(policy: &PolicyRule, trace: &TraceRecord) -> PolicyEvaluation {
28 let (result, details) = match &policy.condition {
29 PolicyCondition::TraceCompleteness => eval_trace_completeness(trace),
30 PolicyCondition::AiPercentageThreshold { .. } => {
31 (
34 EvalResult::Pass,
35 "AI percentage evaluated server-side".into(),
36 )
37 }
38 PolicyCondition::ModelAllowlist { allowed_models } => {
39 eval_model_allowlist(trace, allowed_models)
40 }
41 PolicyCondition::SensitivePathPattern { .. } => {
42 (
44 EvalResult::Pass,
45 "Sensitive path review evaluated server-side".into(),
46 )
47 }
48 PolicyCondition::RequiredToolCall { tool_names, .. } => {
49 eval_required_tool_call(trace, tool_names)
50 }
51 PolicyCondition::TokenBudget {
52 max_tokens,
53 max_cost_usd,
54 } => eval_token_budget(trace, *max_tokens, *max_cost_usd),
55 PolicyCondition::ConditionalToolCall {
56 tool_name,
57 min_count,
58 ..
59 } => eval_conditional_tool_call(trace, tool_name, *min_count),
60 };
61
62 PolicyEvaluation {
63 policy: policy.clone(),
64 result,
65 details,
66 }
67}
68
69fn eval_trace_completeness(trace: &TraceRecord) -> (EvalResult, String) {
70 let has_session = !trace.session.session_id.is_empty();
71 let has_model = trace.model.is_some();
72
73 if has_session && has_model {
74 (EvalResult::Pass, "Trace is complete".into())
75 } else {
76 let mut missing = vec![];
77 if !has_session {
78 missing.push("session");
79 }
80 if !has_model {
81 missing.push("model");
82 }
83 (EvalResult::Warn, format!("Missing: {}", missing.join(", ")))
84 }
85}
86
87fn eval_model_allowlist(trace: &TraceRecord, allowed: &[String]) -> (EvalResult, String) {
88 if allowed.is_empty() {
89 return (EvalResult::Pass, "No allowlist configured".into());
90 }
91 match &trace.model {
92 Some(model) if allowed.iter().any(|a| model.contains(a)) => {
93 (EvalResult::Pass, format!("Model {model} is allowed"))
94 }
95 Some(model) => (
96 EvalResult::Fail,
97 format!("Model {model} is not in allowlist: {}", allowed.join(", ")),
98 ),
99 None => (EvalResult::Fail, "No model specified in trace".into()),
100 }
101}
102
103fn eval_required_tool_call(trace: &TraceRecord, required: &[String]) -> (EvalResult, String) {
104 let used: Vec<_> = trace.session.tools_used.iter().map(|t| &t.name).collect();
105 let missing: Vec<_> = required
106 .iter()
107 .filter(|r| !used.iter().any(|u| u.as_str() == r.as_str()))
108 .collect();
109
110 if missing.is_empty() {
111 (EvalResult::Pass, "All required tools were used".into())
112 } else {
113 (
114 EvalResult::Warn,
115 format!(
116 "Missing required tools: {}",
117 missing
118 .iter()
119 .map(|s| s.as_str())
120 .collect::<Vec<_>>()
121 .join(", ")
122 ),
123 )
124 }
125}
126
127fn eval_token_budget(
128 trace: &TraceRecord,
129 max_tokens: Option<u64>,
130 max_cost: Option<f64>,
131) -> (EvalResult, String) {
132 let tokens = trace.session.token_usage.total_tokens;
133 let cost = trace.session.token_usage.estimated_cost_usd;
134
135 let token_over = max_tokens.is_some_and(|max| tokens > max);
136 let cost_over = max_cost.is_some_and(|max| cost > max);
137
138 if token_over || cost_over {
139 (
140 EvalResult::Warn,
141 format!("Budget exceeded: {tokens} tokens (${cost:.2})"),
142 )
143 } else {
144 (
145 EvalResult::Pass,
146 format!("Within budget: {tokens} tokens (${cost:.2})"),
147 )
148 }
149}
150
151fn eval_conditional_tool_call(
152 trace: &TraceRecord,
153 tool_name: &str,
154 min_count: Option<u32>,
155) -> (EvalResult, String) {
156 let min = min_count.unwrap_or(1) as usize;
157 let count = trace
158 .session
159 .tools_used
160 .iter()
161 .filter(|t| t.name == tool_name)
162 .count();
163
164 if count >= min {
165 (
166 EvalResult::Pass,
167 format!(
168 "Tool '{}' called {} time(s) (required >= {})",
169 tool_name, count, min
170 ),
171 )
172 } else {
173 (
174 EvalResult::Fail,
175 format!(
176 "Tool '{}' called {} time(s) (required >= {})",
177 tool_name, count, min
178 ),
179 )
180 }
181}
182
183fn default_policies() -> Vec<PolicyRule> {
184 vec![
185 PolicyRule {
186 id: Uuid::nil(),
187 org_id: None,
188 name: "Trace completeness".into(),
189 description: "Every AI commit must have complete trace data".into(),
190 condition: PolicyCondition::TraceCompleteness,
191 action: PolicyAction::Warn,
192 severity: PolicySeverity::Medium,
193 scope: Default::default(),
194 enabled: true,
195 },
196 PolicyRule {
197 id: Uuid::nil(),
198 org_id: None,
199 name: "AI percentage threshold".into(),
200 description: "Warn when AI-generated code exceeds 90%".into(),
201 condition: PolicyCondition::AiPercentageThreshold { threshold: 90.0 },
202 action: PolicyAction::Warn,
203 severity: PolicySeverity::Medium,
204 scope: Default::default(),
205 enabled: true,
206 },
207 PolicyRule {
208 id: Uuid::nil(),
209 org_id: None,
210 name: "Model allowlist".into(),
211 description: "Only approved models may generate code".into(),
212 condition: PolicyCondition::ModelAllowlist {
213 allowed_models: vec![
214 "anthropic/claude".into(),
215 "openai/gpt".into(),
216 "google/gemini".into(),
217 ],
218 },
219 action: PolicyAction::BlockPush,
220 severity: PolicySeverity::High,
221 scope: Default::default(),
222 enabled: true,
223 },
224 PolicyRule {
225 id: Uuid::nil(),
226 org_id: None,
227 name: "Sensitive path review".into(),
228 description: "AI code in sensitive paths requires review".into(),
229 condition: PolicyCondition::SensitivePathPattern {
230 patterns: vec![
231 "payments".into(),
232 "auth".into(),
233 "security".into(),
234 "crypto".into(),
235 ],
236 },
237 action: PolicyAction::Warn,
238 severity: PolicySeverity::High,
239 scope: Default::default(),
240 enabled: true,
241 },
242 PolicyRule {
243 id: Uuid::nil(),
244 org_id: None,
245 name: "Required tool call".into(),
246 description: "Trace must show that tests were run".into(),
247 condition: PolicyCondition::RequiredToolCall {
248 tool_names: vec![],
249 must_succeed: false,
250 },
251 action: PolicyAction::Warn,
252 severity: PolicySeverity::Low,
253 scope: Default::default(),
254 enabled: true,
255 },
256 PolicyRule {
257 id: Uuid::nil(),
258 org_id: None,
259 name: "Token budget".into(),
260 description: "Warn when token usage exceeds budget".into(),
261 condition: PolicyCondition::TokenBudget {
262 max_tokens: Some(500_000),
263 max_cost_usd: Some(50.0),
264 },
265 action: PolicyAction::Warn,
266 severity: PolicySeverity::Medium,
267 scope: Default::default(),
268 enabled: true,
269 },
270 ]
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::token_usage::TokenUsage;
277 use crate::trace::{Session, ToolCall};
278 use chrono::Utc;
279
280 fn make_trace(session_id: &str, model: Option<&str>) -> TraceRecord {
281 TraceRecord {
282 id: Uuid::nil(),
283 repo_id: "repo".into(),
284 commit_sha: "abc123".into(),
285 branch: None,
286 author: "dev".into(),
287 created_at: Utc::now(),
288 model: model.map(String::from),
289 tool: "claude-code".into(),
290 tool_version: None,
291 session: Session {
292 session_id: session_id.into(),
293 started_at: Utc::now(),
294 ended_at: None,
295 prompts: vec![],
296 responses: vec![],
297 token_usage: TokenUsage::default(),
298 tools_used: vec![],
299 },
300 agent_trace: None,
301 signature: None,
302 }
303 }
304
305 fn make_trace_with_tools(tools: Vec<&str>) -> TraceRecord {
306 let mut trace = make_trace("sess-1", Some("anthropic/claude-3"));
307 trace.session.tools_used = tools
308 .into_iter()
309 .map(|name| ToolCall {
310 name: name.into(),
311 input_summary: String::new(),
312 timestamp: Utc::now(),
313 })
314 .collect();
315 trace
316 }
317
318 fn make_trace_with_tokens(total_tokens: u64, cost: f64) -> TraceRecord {
319 let mut trace = make_trace("sess-1", Some("anthropic/claude-3"));
320 trace.session.token_usage.total_tokens = total_tokens;
321 trace.session.token_usage.estimated_cost_usd = cost;
322 trace
323 }
324
325 #[test]
328 fn completeness_pass_when_both_present() {
329 let trace = make_trace("sess-1", Some("claude-3"));
330 let (result, _) = eval_trace_completeness(&trace);
331 assert_eq!(result, EvalResult::Pass);
332 }
333
334 #[test]
335 fn completeness_fail_missing_session_id() {
336 let trace = make_trace("", Some("claude-3"));
337 let (result, details) = eval_trace_completeness(&trace);
338 assert_eq!(result, EvalResult::Warn);
339 assert!(details.contains("session"));
340 }
341
342 #[test]
343 fn completeness_fail_missing_model() {
344 let trace = make_trace("sess-1", None);
345 let (result, details) = eval_trace_completeness(&trace);
346 assert_eq!(result, EvalResult::Warn);
347 assert!(details.contains("model"));
348 }
349
350 #[test]
351 fn completeness_fail_both_missing() {
352 let trace = make_trace("", None);
353 let (result, details) = eval_trace_completeness(&trace);
354 assert_eq!(result, EvalResult::Warn);
355 assert!(details.contains("session"));
356 assert!(details.contains("model"));
357 }
358
359 #[test]
362 fn model_allowlist_pass_empty_list() {
363 let trace = make_trace("sess-1", Some("anything"));
364 let (result, _) = eval_model_allowlist(&trace, &[]);
365 assert_eq!(result, EvalResult::Pass);
366 }
367
368 #[test]
369 fn model_allowlist_pass_match() {
370 let trace = make_trace("sess-1", Some("anthropic/claude-3"));
371 let allowed = vec!["anthropic/claude".into(), "openai/gpt".into()];
372 let (result, _) = eval_model_allowlist(&trace, &allowed);
373 assert_eq!(result, EvalResult::Pass);
374 }
375
376 #[test]
377 fn model_allowlist_fail_no_match() {
378 let trace = make_trace("sess-1", Some("unknown/model"));
379 let allowed = vec!["anthropic/claude".into(), "openai/gpt".into()];
380 let (result, _) = eval_model_allowlist(&trace, &allowed);
381 assert_eq!(result, EvalResult::Fail);
382 }
383
384 #[test]
387 fn required_tool_call_pass_all_present() {
388 let trace = make_trace_with_tools(vec!["cargo test", "cargo clippy"]);
389 let required = vec!["cargo test".into(), "cargo clippy".into()];
390 let (result, _) = eval_required_tool_call(&trace, &required);
391 assert_eq!(result, EvalResult::Pass);
392 }
393
394 #[test]
395 fn required_tool_call_warn_missing() {
396 let trace = make_trace_with_tools(vec!["cargo test"]);
397 let required = vec!["cargo test".into(), "cargo clippy".into()];
398 let (result, details) = eval_required_tool_call(&trace, &required);
399 assert_eq!(result, EvalResult::Warn);
400 assert!(details.contains("cargo clippy"));
401 }
402
403 #[test]
406 fn token_budget_pass_under_limits() {
407 let trace = make_trace_with_tokens(1000, 1.0);
408 let (result, _) = eval_token_budget(&trace, Some(5000), Some(10.0));
409 assert_eq!(result, EvalResult::Pass);
410 }
411
412 #[test]
413 fn token_budget_warn_over_max_tokens() {
414 let trace = make_trace_with_tokens(10_000, 1.0);
415 let (result, _) = eval_token_budget(&trace, Some(5000), Some(10.0));
416 assert_eq!(result, EvalResult::Warn);
417 }
418
419 #[test]
420 fn token_budget_warn_over_max_cost() {
421 let trace = make_trace_with_tokens(1000, 20.0);
422 let (result, _) = eval_token_budget(&trace, Some(5000), Some(10.0));
423 assert_eq!(result, EvalResult::Warn);
424 }
425
426 #[test]
427 fn token_budget_pass_both_none() {
428 let trace = make_trace_with_tokens(999_999, 999.0);
429 let (result, _) = eval_token_budget(&trace, None, None);
430 assert_eq!(result, EvalResult::Pass);
431 }
432
433 #[test]
436 fn conditional_tool_call_pass_count_met() {
437 let trace = make_trace_with_tools(vec!["cargo test", "cargo test"]);
438 let (result, _) = eval_conditional_tool_call(&trace, "cargo test", Some(2));
439 assert_eq!(result, EvalResult::Pass);
440 }
441
442 #[test]
443 fn conditional_tool_call_fail_count_not_met() {
444 let trace = make_trace_with_tools(vec!["cargo test"]);
445 let (result, _) = eval_conditional_tool_call(&trace, "cargo test", Some(3));
446 assert_eq!(result, EvalResult::Fail);
447 }
448
449 #[test]
450 fn conditional_tool_call_fail_absent() {
451 let trace = make_trace_with_tools(vec!["cargo clippy"]);
452 let (result, _) = eval_conditional_tool_call(&trace, "cargo test", Some(1));
453 assert_eq!(result, EvalResult::Fail);
454 }
455
456 #[test]
459 fn evaluate_skips_disabled_policies() {
460 let policies = vec![
461 PolicyRule {
462 id: Uuid::nil(),
463 org_id: None,
464 name: "enabled".into(),
465 description: String::new(),
466 condition: PolicyCondition::TraceCompleteness,
467 action: PolicyAction::Warn,
468 severity: PolicySeverity::Low,
469 scope: Default::default(),
470 enabled: true,
471 },
472 PolicyRule {
473 id: Uuid::nil(),
474 org_id: None,
475 name: "disabled".into(),
476 description: String::new(),
477 condition: PolicyCondition::TraceCompleteness,
478 action: PolicyAction::Warn,
479 severity: PolicySeverity::Low,
480 scope: Default::default(),
481 enabled: false,
482 },
483 ];
484 let engine = PolicyEngine::new(policies);
485 let trace = make_trace("sess-1", Some("claude-3"));
486 let results = engine.evaluate(&trace);
487 assert_eq!(results.len(), 1);
488 assert_eq!(results[0].policy.name, "enabled");
489 }
490
491 #[test]
492 fn with_defaults_returns_six_enabled() {
493 let engine = PolicyEngine::with_defaults();
494 let trace = make_trace("sess-1", Some("anthropic/claude-3"));
495 let results = engine.evaluate(&trace);
496 assert_eq!(results.len(), 6);
497 assert!(results.iter().all(|r| r.policy.enabled));
498 }
499}