Skip to main content

tracevault_core/
policy_engine.rs

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            // AI percentage is now computed server-side from commit_attributions.
32            // The client-side policy engine cannot evaluate this.
33            (
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            // Sensitive path checking requires attribution data, now server-side only.
43            (
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    // --- TraceCompleteness ---
326
327    #[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    // --- ModelAllowlist ---
360
361    #[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    // --- RequiredToolCall ---
385
386    #[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    // --- TokenBudget ---
404
405    #[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    // --- ConditionalToolCall ---
434
435    #[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    // --- PolicyEngine ---
457
458    #[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}