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