Skip to main content

tracevault_core/
policy_eval.rs

1//! Shared policy evaluation logic used by both the CLI check flow (via the
2//! server) and the server's check endpoint. Single source of truth so glob
3//! semantics and tool-name matching can't drift.
4
5use std::collections::HashMap;
6
7pub struct EvalOutcome {
8    pub passed: bool,
9    pub details: String,
10}
11
12/// Per-tool call statistics used during policy evaluation.
13#[derive(Debug, Default, Clone)]
14pub struct ToolCallStats {
15    /// Total number of times the tool was called.
16    pub total: i64,
17    /// Number of calls where is_error = false (confirmed successful).
18    /// NULL is_error values are NOT counted as successful.
19    pub successful: i64,
20}
21
22/// Evaluate a condition JSON document against aggregated session data.
23///
24/// `tool_calls` maps tool name → ToolCallStats across all sessions in the
25/// push. `files_modified` is the union of file paths touched.
26pub fn evaluate_condition(
27    condition: &serde_json::Value,
28    tool_calls: &HashMap<String, ToolCallStats>,
29    files_modified: &[String],
30) -> EvalOutcome {
31    let cond_type = condition.get("type").and_then(|v| v.as_str()).unwrap_or("");
32
33    match cond_type {
34        "RequiredToolCall" => eval_required(condition, tool_calls),
35        "ConditionalToolCall" => eval_conditional(condition, tool_calls, files_modified),
36        "AiPercentageThreshold" => EvalOutcome {
37            passed: true,
38            details: "AI percentage not evaluated in check (requires attribution data)".into(),
39        },
40        "TokenBudget" => EvalOutcome {
41            passed: true,
42            details: "Token budget not evaluated in check (requires token data)".into(),
43        },
44        _ => EvalOutcome {
45            passed: true,
46            details: format!("Unknown condition type '{cond_type}', skipped"),
47        },
48    }
49}
50
51fn eval_required(
52    condition: &serde_json::Value,
53    tool_calls: &HashMap<String, ToolCallStats>,
54) -> EvalOutcome {
55    let tool_names: Vec<String> = condition
56        .get("tool_names")
57        .and_then(|v| v.as_array())
58        .map(|arr| {
59            arr.iter()
60                .filter_map(|v| v.as_str().map(String::from))
61                .collect()
62        })
63        .unwrap_or_default();
64
65    let must_succeed = condition
66        .get("must_succeed")
67        .and_then(|v| v.as_bool())
68        .unwrap_or(false);
69
70    let missing: Vec<&String> = tool_names
71        .iter()
72        .filter(|name| {
73            let stats = tool_calls.get(name.as_str());
74            if must_succeed {
75                stats.map(|s| s.successful).unwrap_or(0) == 0
76            } else {
77                stats.map(|s| s.total).unwrap_or(0) == 0
78            }
79        })
80        .collect();
81
82    if missing.is_empty() {
83        EvalOutcome {
84            passed: true,
85            details: if must_succeed {
86                "All required tools were used and succeeded".into()
87            } else {
88                "All required tools were used".into()
89            },
90        }
91    } else {
92        EvalOutcome {
93            passed: false,
94            details: format!(
95                "{}: {}",
96                if must_succeed {
97                    "Required tools not called or failed"
98                } else {
99                    "Missing required tools"
100                },
101                missing
102                    .iter()
103                    .map(|s| s.as_str())
104                    .collect::<Vec<_>>()
105                    .join(", ")
106            ),
107        }
108    }
109}
110
111fn eval_conditional(
112    condition: &serde_json::Value,
113    tool_calls: &HashMap<String, ToolCallStats>,
114    files_modified: &[String],
115) -> EvalOutcome {
116    let tool_name = condition
117        .get("tool_name")
118        .and_then(|v| v.as_str())
119        .unwrap_or("");
120    let min_count = condition
121        .get("min_count")
122        .and_then(|v| v.as_u64())
123        .unwrap_or(1) as i64;
124    let file_patterns = condition
125        .get("when_files_match")
126        .and_then(|v| v.as_array())
127        .map(|arr| {
128            arr.iter()
129                .filter_map(|v| v.as_str().map(String::from))
130                .collect::<Vec<_>>()
131        });
132
133    let patterns_match = match &file_patterns {
134        None => true,
135        Some(patterns) if patterns.is_empty() => true,
136        Some(patterns) => files_modified.iter().any(|file| {
137            patterns
138                .iter()
139                .any(|pattern| glob_match::glob_match(pattern, file))
140        }),
141    };
142
143    if !patterns_match {
144        return EvalOutcome {
145            passed: true,
146            details: "Rule skipped: no modified files match patterns".into(),
147        };
148    }
149
150    let must_succeed = condition
151        .get("must_succeed")
152        .and_then(|v| v.as_bool())
153        .unwrap_or(false);
154
155    let stats = tool_calls.get(tool_name);
156    let actual_count = if must_succeed {
157        stats.map(|s| s.successful).unwrap_or(0)
158    } else {
159        stats.map(|s| s.total).unwrap_or(0)
160    };
161    let total_count = stats.map(|s| s.total).unwrap_or(0);
162
163    let passed = actual_count >= min_count;
164    EvalOutcome {
165        passed,
166        details: if must_succeed {
167            format!(
168                "Tool '{tool_name}' called {total_count} time(s), {actual_count} succeeded (required >= {min_count} successful)"
169            )
170        } else {
171            format!("Tool '{tool_name}' called {actual_count} time(s) (required >= {min_count})")
172        },
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use serde_json::json;
180
181    fn tools(pairs: &[(&str, i64)]) -> HashMap<String, ToolCallStats> {
182        pairs
183            .iter()
184            .map(|(k, v)| {
185                (
186                    k.to_string(),
187                    ToolCallStats {
188                        total: *v,
189                        successful: *v, // assume all successful unless test specifies otherwise
190                    },
191                )
192            })
193            .collect()
194    }
195
196    fn tools_with_errors(pairs: &[(&str, i64, i64)]) -> HashMap<String, ToolCallStats> {
197        pairs
198            .iter()
199            .map(|(k, total, successful)| {
200                (
201                    k.to_string(),
202                    ToolCallStats {
203                        total: *total,
204                        successful: *successful,
205                    },
206                )
207            })
208            .collect()
209    }
210
211    #[test]
212    fn required_all_present_exact_match() {
213        let cond = json!({"type": "RequiredToolCall", "tool_names": ["Read", "Write"]});
214        let t = tools(&[("Read", 1), ("Write", 1)]);
215        assert!(evaluate_condition(&cond, &t, &[]).passed);
216    }
217
218    #[test]
219    fn required_substring_does_not_match() {
220        // Previously `Read` matched `read_file` via substring. Now: exact only.
221        let cond = json!({"type": "RequiredToolCall", "tool_names": ["Read"]});
222        let t = tools(&[("read_file", 5)]);
223        assert!(!evaluate_condition(&cond, &t, &[]).passed);
224    }
225
226    #[test]
227    fn required_missing_reports_names() {
228        let cond = json!({"type": "RequiredToolCall", "tool_names": ["Lint"]});
229        let out = evaluate_condition(&cond, &HashMap::new(), &[]);
230        assert!(!out.passed);
231        assert!(out.details.contains("Lint"));
232    }
233
234    #[test]
235    fn conditional_glob_file_match_count_met() {
236        let cond = json!({
237            "type": "ConditionalToolCall",
238            "tool_name": "security_scan",
239            "min_count": 1,
240            "when_files_match": ["**/*.rs"]
241        });
242        let t = tools(&[("security_scan", 2)]);
243        let files = vec!["src/main.rs".to_string()];
244        assert!(evaluate_condition(&cond, &t, &files).passed);
245    }
246
247    #[test]
248    fn conditional_count_not_met_fails() {
249        let cond = json!({
250            "type": "ConditionalToolCall",
251            "tool_name": "security_scan",
252            "min_count": 5,
253            "when_files_match": ["**/*.rs"]
254        });
255        let t = tools(&[("security_scan", 1)]);
256        let files = vec!["src/main.rs".to_string()];
257        assert!(!evaluate_condition(&cond, &t, &files).passed);
258    }
259
260    #[test]
261    fn conditional_no_file_match_skips() {
262        let cond = json!({
263            "type": "ConditionalToolCall",
264            "tool_name": "security_scan",
265            "min_count": 1,
266            "when_files_match": ["*.py"]
267        });
268        let files = vec!["src/main.rs".to_string()];
269        let out = evaluate_condition(&cond, &HashMap::new(), &files);
270        assert!(out.passed);
271        assert!(out.details.contains("skipped"));
272    }
273
274    #[test]
275    fn conditional_no_patterns_always_applies() {
276        let cond = json!({
277            "type": "ConditionalToolCall",
278            "tool_name": "test",
279            "min_count": 1
280        });
281        let out = evaluate_condition(&cond, &HashMap::new(), &[]);
282        assert!(!out.passed);
283    }
284
285    #[test]
286    fn conditional_tool_name_exact_only() {
287        // Previously `security_scan` matched `my_security_scan_v2` via substring.
288        let cond = json!({
289            "type": "ConditionalToolCall",
290            "tool_name": "security_scan",
291            "min_count": 1
292        });
293        let t = tools(&[("my_security_scan_v2", 10)]);
294        assert!(!evaluate_condition(&cond, &t, &[]).passed);
295    }
296
297    #[test]
298    fn unknown_condition_passes() {
299        let cond = json!({"type": "FutureCondition"});
300        assert!(evaluate_condition(&cond, &HashMap::new(), &[]).passed);
301    }
302
303    #[test]
304    fn required_must_succeed_all_failed_fails() {
305        let cond =
306            json!({"type": "RequiredToolCall", "tool_names": ["Bash"], "must_succeed": true});
307        // Called 3 times but 0 successful
308        let t = tools_with_errors(&[("Bash", 3, 0)]);
309        let out = evaluate_condition(&cond, &t, &[]);
310        assert!(!out.passed);
311        assert!(out.details.contains("failed"));
312    }
313
314    #[test]
315    fn required_must_succeed_one_success_passes() {
316        let cond =
317            json!({"type": "RequiredToolCall", "tool_names": ["Bash"], "must_succeed": true});
318        let t = tools_with_errors(&[("Bash", 3, 1)]);
319        assert!(evaluate_condition(&cond, &t, &[]).passed);
320    }
321
322    #[test]
323    fn required_must_succeed_false_counts_all_calls() {
324        // Without must_succeed, even all-failed calls satisfy the policy
325        let cond =
326            json!({"type": "RequiredToolCall", "tool_names": ["Bash"], "must_succeed": false});
327        let t = tools_with_errors(&[("Bash", 3, 0)]);
328        assert!(evaluate_condition(&cond, &t, &[]).passed);
329    }
330
331    #[test]
332    fn conditional_must_succeed_counts_only_successes() {
333        let cond = json!({
334            "type": "ConditionalToolCall",
335            "tool_name": "security_scan",
336            "min_count": 2,
337            "must_succeed": true
338        });
339        // 5 total calls, only 1 successful — should fail min_count=2
340        let t = tools_with_errors(&[("security_scan", 5, 1)]);
341        assert!(!evaluate_condition(&cond, &t, &[]).passed);
342    }
343
344    #[test]
345    fn conditional_must_succeed_passes_when_enough_successes() {
346        let cond = json!({
347            "type": "ConditionalToolCall",
348            "tool_name": "security_scan",
349            "min_count": 2,
350            "must_succeed": true
351        });
352        let t = tools_with_errors(&[("security_scan", 5, 3)]);
353        assert!(evaluate_condition(&cond, &t, &[]).passed);
354    }
355}