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            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    // --- TraceCompleteness ---
320
321    #[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    // --- ModelAllowlist ---
354
355    #[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    // --- RequiredToolCall ---
379
380    #[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    // --- TokenBudget ---
398
399    #[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    // --- ConditionalToolCall ---
428
429    #[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    // --- PolicyEngine ---
451
452    #[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}