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 enabled: true,
194 },
195 PolicyRule {
196 id: Uuid::nil(),
197 org_id: None,
198 name: "AI percentage threshold".into(),
199 description: "Warn when AI-generated code exceeds 90%".into(),
200 condition: PolicyCondition::AiPercentageThreshold { threshold: 90.0 },
201 action: PolicyAction::Warn,
202 severity: PolicySeverity::Medium,
203 enabled: true,
204 },
205 PolicyRule {
206 id: Uuid::nil(),
207 org_id: None,
208 name: "Model allowlist".into(),
209 description: "Only approved models may generate code".into(),
210 condition: PolicyCondition::ModelAllowlist {
211 allowed_models: vec![
212 "anthropic/claude".into(),
213 "openai/gpt".into(),
214 "google/gemini".into(),
215 ],
216 },
217 action: PolicyAction::BlockPush,
218 severity: PolicySeverity::High,
219 enabled: true,
220 },
221 PolicyRule {
222 id: Uuid::nil(),
223 org_id: None,
224 name: "Sensitive path review".into(),
225 description: "AI code in sensitive paths requires review".into(),
226 condition: PolicyCondition::SensitivePathPattern {
227 patterns: vec![
228 "payments".into(),
229 "auth".into(),
230 "security".into(),
231 "crypto".into(),
232 ],
233 },
234 action: PolicyAction::Warn,
235 severity: PolicySeverity::High,
236 enabled: true,
237 },
238 PolicyRule {
239 id: Uuid::nil(),
240 org_id: None,
241 name: "Required tool call".into(),
242 description: "Trace must show that tests were run".into(),
243 condition: PolicyCondition::RequiredToolCall {
244 tool_names: vec![],
245 must_succeed: false,
246 },
247 action: PolicyAction::Warn,
248 severity: PolicySeverity::Low,
249 enabled: true,
250 },
251 PolicyRule {
252 id: Uuid::nil(),
253 org_id: None,
254 name: "Token budget".into(),
255 description: "Warn when token usage exceeds budget".into(),
256 condition: PolicyCondition::TokenBudget {
257 max_tokens: Some(500_000),
258 max_cost_usd: Some(50.0),
259 },
260 action: PolicyAction::Warn,
261 severity: PolicySeverity::Medium,
262 enabled: true,
263 },
264 ]
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::token_usage::TokenUsage;
271 use crate::trace::{Session, ToolCall};
272 use chrono::Utc;
273
274 fn make_trace(session_id: &str, model: Option<&str>) -> TraceRecord {
275 TraceRecord {
276 id: Uuid::nil(),
277 repo_id: "repo".into(),
278 commit_sha: "abc123".into(),
279 branch: None,
280 author: "dev".into(),
281 created_at: Utc::now(),
282 model: model.map(String::from),
283 tool: "claude-code".into(),
284 tool_version: None,
285 session: Session {
286 session_id: session_id.into(),
287 started_at: Utc::now(),
288 ended_at: None,
289 prompts: vec![],
290 responses: vec![],
291 token_usage: TokenUsage::default(),
292 tools_used: vec![],
293 },
294 agent_trace: None,
295 signature: None,
296 }
297 }
298
299 fn make_trace_with_tools(tools: Vec<&str>) -> TraceRecord {
300 let mut trace = make_trace("sess-1", Some("anthropic/claude-3"));
301 trace.session.tools_used = tools
302 .into_iter()
303 .map(|name| ToolCall {
304 name: name.into(),
305 input_summary: String::new(),
306 timestamp: Utc::now(),
307 })
308 .collect();
309 trace
310 }
311
312 fn make_trace_with_tokens(total_tokens: u64, cost: f64) -> TraceRecord {
313 let mut trace = make_trace("sess-1", Some("anthropic/claude-3"));
314 trace.session.token_usage.total_tokens = total_tokens;
315 trace.session.token_usage.estimated_cost_usd = cost;
316 trace
317 }
318
319 #[test]
322 fn completeness_pass_when_both_present() {
323 let trace = make_trace("sess-1", Some("claude-3"));
324 let (result, _) = eval_trace_completeness(&trace);
325 assert_eq!(result, EvalResult::Pass);
326 }
327
328 #[test]
329 fn completeness_fail_missing_session_id() {
330 let trace = make_trace("", Some("claude-3"));
331 let (result, details) = eval_trace_completeness(&trace);
332 assert_eq!(result, EvalResult::Warn);
333 assert!(details.contains("session"));
334 }
335
336 #[test]
337 fn completeness_fail_missing_model() {
338 let trace = make_trace("sess-1", None);
339 let (result, details) = eval_trace_completeness(&trace);
340 assert_eq!(result, EvalResult::Warn);
341 assert!(details.contains("model"));
342 }
343
344 #[test]
345 fn completeness_fail_both_missing() {
346 let trace = make_trace("", None);
347 let (result, details) = eval_trace_completeness(&trace);
348 assert_eq!(result, EvalResult::Warn);
349 assert!(details.contains("session"));
350 assert!(details.contains("model"));
351 }
352
353 #[test]
356 fn model_allowlist_pass_empty_list() {
357 let trace = make_trace("sess-1", Some("anything"));
358 let (result, _) = eval_model_allowlist(&trace, &[]);
359 assert_eq!(result, EvalResult::Pass);
360 }
361
362 #[test]
363 fn model_allowlist_pass_match() {
364 let trace = make_trace("sess-1", Some("anthropic/claude-3"));
365 let allowed = vec!["anthropic/claude".into(), "openai/gpt".into()];
366 let (result, _) = eval_model_allowlist(&trace, &allowed);
367 assert_eq!(result, EvalResult::Pass);
368 }
369
370 #[test]
371 fn model_allowlist_fail_no_match() {
372 let trace = make_trace("sess-1", Some("unknown/model"));
373 let allowed = vec!["anthropic/claude".into(), "openai/gpt".into()];
374 let (result, _) = eval_model_allowlist(&trace, &allowed);
375 assert_eq!(result, EvalResult::Fail);
376 }
377
378 #[test]
381 fn required_tool_call_pass_all_present() {
382 let trace = make_trace_with_tools(vec!["cargo test", "cargo clippy"]);
383 let required = vec!["cargo test".into(), "cargo clippy".into()];
384 let (result, _) = eval_required_tool_call(&trace, &required);
385 assert_eq!(result, EvalResult::Pass);
386 }
387
388 #[test]
389 fn required_tool_call_warn_missing() {
390 let trace = make_trace_with_tools(vec!["cargo test"]);
391 let required = vec!["cargo test".into(), "cargo clippy".into()];
392 let (result, details) = eval_required_tool_call(&trace, &required);
393 assert_eq!(result, EvalResult::Warn);
394 assert!(details.contains("cargo clippy"));
395 }
396
397 #[test]
400 fn token_budget_pass_under_limits() {
401 let trace = make_trace_with_tokens(1000, 1.0);
402 let (result, _) = eval_token_budget(&trace, Some(5000), Some(10.0));
403 assert_eq!(result, EvalResult::Pass);
404 }
405
406 #[test]
407 fn token_budget_warn_over_max_tokens() {
408 let trace = make_trace_with_tokens(10_000, 1.0);
409 let (result, _) = eval_token_budget(&trace, Some(5000), Some(10.0));
410 assert_eq!(result, EvalResult::Warn);
411 }
412
413 #[test]
414 fn token_budget_warn_over_max_cost() {
415 let trace = make_trace_with_tokens(1000, 20.0);
416 let (result, _) = eval_token_budget(&trace, Some(5000), Some(10.0));
417 assert_eq!(result, EvalResult::Warn);
418 }
419
420 #[test]
421 fn token_budget_pass_both_none() {
422 let trace = make_trace_with_tokens(999_999, 999.0);
423 let (result, _) = eval_token_budget(&trace, None, None);
424 assert_eq!(result, EvalResult::Pass);
425 }
426
427 #[test]
430 fn conditional_tool_call_pass_count_met() {
431 let trace = make_trace_with_tools(vec!["cargo test", "cargo test"]);
432 let (result, _) = eval_conditional_tool_call(&trace, "cargo test", Some(2));
433 assert_eq!(result, EvalResult::Pass);
434 }
435
436 #[test]
437 fn conditional_tool_call_fail_count_not_met() {
438 let trace = make_trace_with_tools(vec!["cargo test"]);
439 let (result, _) = eval_conditional_tool_call(&trace, "cargo test", Some(3));
440 assert_eq!(result, EvalResult::Fail);
441 }
442
443 #[test]
444 fn conditional_tool_call_fail_absent() {
445 let trace = make_trace_with_tools(vec!["cargo clippy"]);
446 let (result, _) = eval_conditional_tool_call(&trace, "cargo test", Some(1));
447 assert_eq!(result, EvalResult::Fail);
448 }
449
450 #[test]
453 fn evaluate_skips_disabled_policies() {
454 let policies = vec![
455 PolicyRule {
456 id: Uuid::nil(),
457 org_id: None,
458 name: "enabled".into(),
459 description: String::new(),
460 condition: PolicyCondition::TraceCompleteness,
461 action: PolicyAction::Warn,
462 severity: PolicySeverity::Low,
463 enabled: true,
464 },
465 PolicyRule {
466 id: Uuid::nil(),
467 org_id: None,
468 name: "disabled".into(),
469 description: String::new(),
470 condition: PolicyCondition::TraceCompleteness,
471 action: PolicyAction::Warn,
472 severity: PolicySeverity::Low,
473 enabled: false,
474 },
475 ];
476 let engine = PolicyEngine::new(policies);
477 let trace = make_trace("sess-1", Some("claude-3"));
478 let results = engine.evaluate(&trace);
479 assert_eq!(results.len(), 1);
480 assert_eq!(results[0].policy.name, "enabled");
481 }
482
483 #[test]
484 fn with_defaults_returns_six_enabled() {
485 let engine = PolicyEngine::with_defaults();
486 let trace = make_trace("sess-1", Some("anthropic/claude-3"));
487 let results = engine.evaluate(&trace);
488 assert_eq!(results.len(), 6);
489 assert!(results.iter().all(|r| r.policy.enabled));
490 }
491}